Compare commits

...

218 commits

Author SHA1 Message Date
HAM
426ffb27a7 fix: update Dockerfile
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2026-01-12 15:05:21 +07:00
HAM
5d3997343f fix: Dockerfile
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2026-01-12 14:52:16 +07:00
HAM
a10626e756 update: entrypoint.sh
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2026-01-12 14:49:14 +07:00
HAM
c11fed9832 fix: lock prisma to v6 and fix lockfile
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2026-01-12 14:10:11 +07:00
HAM
8909a763c9 chore: remove unuse file
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2026-01-12 13:51:54 +07:00
HAM
f0a106e5fe fix: use prisma v6 config
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2026-01-12 13:48:10 +07:00
HAM
84b9ddcd2b fix: prisma v7 config in schema.prisma
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2026-01-12 13:41:00 +07:00
HAM
e54f62a5b3 fix: prisma v7 config
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2026-01-12 13:39:04 +07:00
HAM
cef26278ba fix: missing prisma query take and skip
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 15s
2026-01-12 13:15:31 +07:00
net
16c4c64c89 refactor: handle code error
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-10-14 17:12:00 +07:00
HAM
78669ed7ae feat: check invalid data for create taskOrder
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-10-14 16:19:37 +07:00
HAM
5dc88c22dc feat: delete created data when flow account create fail one
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-09-29 09:44:41 +07:00
Methapon2001
3454e46212 feat: flowaccount handle installments
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-19 16:17:56 +07:00
HAM
334fb57b46 refactor: flowaccount move product code to front for product name
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-19 16:09:21 +07:00
HAM
a9201f715a fix: missing product code, buyPrice when create and edit and add (ราคาตัวแทน) for agentPrice product name
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-19 15:35:19 +07:00
Methapon2001
95ee32fc57 feat: response branch api
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-19 11:13:42 +07:00
Methapon2001
a33983c530 fix: transaction ended before create
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-09-19 09:11:21 +07:00
Methapon2001
e8278b6af3 Merge branch 'develop' 2025-09-18 17:12:16 +07:00
Methapon2001
9ef006c860 refactor: handle flow account installments
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-18 17:12:02 +07:00
Methapon2001
8d52a5e726 Merge branch 'develop' 2025-09-18 17:08:13 +07:00
Methapon2001
7858291ae5 fix: receipt flow account
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-18 17:04:40 +07:00
Methapon2001
6ea672a2cb Merge branch 'develop' 2025-09-18 16:56:18 +07:00
Methapon2001
a426e18025 fix: wrong number
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-18 16:55:34 +07:00
Methapon2001
0930c3c833 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-18 10:15:36 +07:00
Methapon2001
be3c6405c6 feat: export product
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-09-18 10:15:32 +07:00
HAM
f50285161b fix: variable name for filter
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-18 10:13:39 +07:00
Methapon2001
4691d559f5 Merge branch 'develop' 2025-09-18 09:50:52 +07:00
HAM
7e7b8025c9 refactor: change to id
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-17 12:58:14 +07:00
HAM
e5a3d948a5 fix: missing query
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-09-17 12:39:10 +07:00
HAM
0d78ce4db3 feat: filter businessType, province, district, subDistrict for customer and customer-export endpoint 2025-09-17 12:37:41 +07:00
Methapon2001
9e74fb0fe6 Merge branch 'develop' 2025-09-17 12:01:55 +07:00
Methapon2001
ab4ea4ba4b fix: type error
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-17 11:55:06 +07:00
Methapon2001
068ba2d293 fix: wrong tax
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-17 11:53:15 +07:00
Methapon2001
beb7f4bcfe fix: flowaccount contact name
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-09-17 11:34:54 +07:00
Methapon2001
b6b35509e3 Merge branch 'develop' 2025-09-17 10:42:40 +07:00
Methapon2001
6598cd3bdf fix: error field ambigous 2025-09-17 10:42:35 +07:00
Methapon2001
2c790de606 fix: customer status not change after used 2025-09-17 09:51:35 +07:00
Methapon2001
6f1bca5234 Merge branch 'develop' 2025-09-16 13:20:30 +07:00
Methapon2001
158a6ff163 fix: timeout in some case
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-16 11:52:13 +07:00
Methapon2001
0772e4710a fix: cannot update payment data after set payment completed
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-16 11:44:13 +07:00
Methapon2001
25a4b50f8e fix: wrong condition
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-16 10:11:02 +07:00
Methapon2001
de33d03631 fix: calculate price
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-16 10:08:01 +07:00
HAM
4e71343af7 refactor: use function for correct decimal number
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-09-16 09:53:03 +07:00
HAM
6776188f7b fix: decimal number
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-16 09:50:23 +07:00
Methapon2001
892d76583f chore: flowaccount enable inline vat
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-16 09:48:59 +07:00
Methapon2001
f2def1b962 fix: wrong calc vat condition
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-16 09:40:31 +07:00
Methapon2001
1486ce79ab fix: calculate credit note price 2025-09-16 09:40:31 +07:00
HAM
d51531cd4d chore: clean
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-12 15:29:52 +07:00
HAM
61825309d1 feat: sync data from data to flowAccount
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-09-12 15:20:20 +07:00
HAM
250bbca226 feat: create product for flowAccount service 2025-09-12 15:19:51 +07:00
HAM
c774e9f44c chore: migration
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-12 11:56:58 +07:00
HAM
8f2810ea29 feat: add flowAccountProductId field 2025-09-12 11:56:40 +07:00
Methapon2001
eda0edbd29 feat: send remark to flowaccount
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-09-11 17:23:21 +07:00
Methapon2001
86db927efe fix: price calc
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-11 13:57:23 +07:00
Methapon2001
5674a18cc3 fix: price calc match with flow account
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-09-11 11:48:17 +07:00
Methapon2001
d3c5b49649 chore: update prisma
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-11 09:32:38 +07:00
Methapon2001
c47ffb5435 chore: migration
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-09-11 09:17:52 +07:00
Methapon2001
710382d544 feat: add more metadata for payment 2025-09-11 09:17:23 +07:00
Methapon2001
4042cbcea4 chore: add html to text dep
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-09-11 09:07:30 +07:00
Methapon2001
f487a9169c fix: prevent line user id and otp exposes 2025-09-10 11:44:22 +07:00
Methapon2001
ab8fd2ca43 feat: export customer and employee as csv 2025-09-10 11:44:22 +07:00
Methapon2001
d95eb349ec fix: update messenger also update work step messenger 2025-09-10 11:44:22 +07:00
Methapon2001
893eb4cca5 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-08-22 09:42:03 +07:00
Methapon2001
2c9fae400c fix: permission employee
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-08-22 09:41:43 +07:00
Methapon2001
df38eebbcc fix: permission
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-08-22 09:33:49 +07:00
Methapon2001
c2eaa5fba8 chore: migration
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-08-14 13:10:49 +07:00
Methapon2001
1789fe1de0 feat: add updated at to step status 2025-08-14 13:10:44 +07:00
Methapon2001
a0bb23e1e8 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-08-04 09:02:06 +07:00
Methapon2001
c9939bf8bb fix: stats will only take non canceled status
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-08-04 09:01:56 +07:00
Methapon2001
f162081370 fix: dashboard payment now taken cancled into account
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-08-01 10:58:04 +07:00
Methapon2001
dfe7bd16d8 fix: error trying to create relation in create many
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-29 10:18:33 +07:00
Methapon2001
bfc2608af4 fix: notification not show because of missing registered branch
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-07-29 09:44:25 +07:00
Methapon2001
23334a4388 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-07-25 09:46:27 +07:00
Methapon2001
9e208dee89 feat: notify document_checker when task is canceled
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-25 09:42:22 +07:00
Methapon2001
a30bc33b81 fix: create response
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-07-22 14:38:29 +07:00
Methapon2001
c7183887c9 fix: error create many with relation
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-22 10:35:48 +07:00
Methapon2001
c34af75bad Merge branch 'fix/line-alert' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-21 17:59:21 +07:00
Kanjana
5de1f27fca feat: add alert RequestData and CreditNote
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-21 17:57:00 +07:00
Methapon2001
8a87e37097 fix: search and pendingOnly get mixed
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-07-21 13:36:40 +07:00
Methapon2001
92762c512d Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-21 11:17:32 +07:00
Methapon2001
3455ae604a feat: more notification related to task and request
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-21 11:17:23 +07:00
Methapon2001
afb89ef949 feat: notify document check when status changed
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-07-21 10:38:31 +07:00
Methapon2001
fa8aa8c9b6 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-17 08:58:02 +07:00
Methapon2001
5c824a738a feat: notify more group when create new quotation
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-07-16 12:59:15 +07:00
Methapon2001
e4caeaa780 fix: new request does not trigger noti
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-16 12:54:52 +07:00
Methapon2001
46ba857e28 fix: missing type
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-16 11:38:11 +07:00
Methapon2001
b7a13b2d7a feat: handle foreign address
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-16 09:51:27 +07:00
Methapon2001
5aa8b06cf2 chore: migration 2025-07-16 09:44:50 +07:00
Methapon2001
f90ee41a56 feat: add address text en 2025-07-16 09:44:46 +07:00
Methapon2001
2ee0e97953 chore: migration
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-16 09:38:34 +07:00
Methapon2001
236ee48eab feat: foreign address 2025-07-16 09:38:30 +07:00
Methapon2001
6c350b12ce fix: prisma client error
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-15 15:17:53 +07:00
Methapon2001
0c53ac69b0 fix: auth error
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-14 11:13:33 +07:00
Methapon2001
0032ff4658 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-11 13:36:52 +07:00
Methapon2001
1f34ea7ecb feat: include business relation
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-11 11:13:18 +07:00
Methapon2001
ccee309268 feat: deprecated business type helper
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-09 17:46:27 +07:00
Methapon2001
1cf53c91aa feat: doc template now include business type relation
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 17:45:14 +07:00
Kanjana
36c9c25f61 Merge remote-tracking branch 'chamomind/feat/20250709' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 17:41:31 +07:00
Kanjana
1f63089363 fest: add page
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 17:40:24 +07:00
Methapon2001
c2195d4448 Merge branch 'feat/20250709' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 17:35:59 +07:00
Kanjana
7112a545b1 feat: add crud businessType
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 17:31:46 +07:00
Kanjana
15e0e34a47 feat: change listAllowed
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 17:26:49 +07:00
Methapon2001
126e00baf1 fix: stats does not account for permission
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 16:34:01 +07:00
Kanjana
163f07758e Merge branch 'feat/20250709' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 16:12:58 +07:00
Kanjana
73fea9d9ed feat: add businessType
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 16:12:13 +07:00
Kanjana
50fca4d540 feat: remove customerName add reportDate
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 16:02:45 +07:00
Kanjana
a61bd8c83e chore: migration
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 15:21:00 +07:00
Methapon2001
8fb28ec3ab fix: perm
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 11:40:39 +07:00
Methapon2001
ab1d5f1326 Merge branch 'develop' 2025-07-09 10:15:04 +07:00
Methapon2001
e74516ce3b fix: allow anyone in company to see template
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-09 09:59:13 +07:00
Methapon2001
f9c4d579c4 feat: allow manage role to update any product under same head
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-08 17:17:07 +07:00
Methapon2001
842d81026e feat: allow anybody to edit customer data if can manage 2025-07-08 17:04:35 +07:00
Methapon2001
86085a74ba Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-08 10:00:03 +07:00
Kanjana
3d98f9d0ad feat: add null in update dateOfBirth
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-07 17:55:32 +07:00
Kanjana
8d25dda326 feat: change where sellerId
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-07-07 13:50:28 +07:00
Methapon2001
138031f662 fix: error not found
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-07-04 15:02:44 +07:00
Kanjana
e0be1f6ab5 feat: add sellerId in quotation
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-04 14:40:46 +07:00
Kanjana
859a1e3def chore: migration
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-04 14:08:54 +07:00
Methapon2001
2b255ff355 feat: do not allow sale to delete data
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-04 13:31:04 +07:00
Methapon2001
ced55b9518 feat: allow sale to manage 2025-07-04 13:31:04 +07:00
Methapon2001
1e0f97cdef feat(perm): allow anyone to edit owned data
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-03 16:20:23 +07:00
Methapon2001
5c7db2afc6 feat(perm): update api quotation perm
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-07-03 14:41:38 +07:00
Methapon2001
d6212e9ba4 Merge branch 'feat/permission' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-03 14:33:21 +07:00
Methapon2001
68025aad08 feat(perm): update api account related permission
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-03 14:33:17 +07:00
Methapon2001
d08327afb6 feat(perm): update api task permission 2025-07-03 14:33:17 +07:00
Methapon2001
afb725fceb feat(perm): update api product/service permission 2025-07-03 14:33:17 +07:00
Methapon2001
b0e941085e feat(perm): update api institue permission 2025-07-03 14:33:17 +07:00
Methapon2001
6d44d2979b feat(perm): update api flow template permission 2025-07-03 14:33:17 +07:00
Methapon2001
fa95fe46a5 feat(perm): update api customer/employee perm 2025-07-03 14:33:17 +07:00
Methapon2001
41f5de7fd0 feat(perm): update api user perm 2025-07-03 14:33:17 +07:00
Methapon2001
425e99bfde feat(perm): update api branch perm 2025-07-03 14:33:17 +07:00
Methapon2001
15381c089c fix: employee stats not working as expected
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-07-03 14:33:10 +07:00
Methapon2001
f7ec18fd7e Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-06-30 16:14:38 +07:00
Methapon2001
acd6bb35e9 chore: migration
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-06-30 16:14:36 +07:00
Methapon2001
1a33080ac8 fix: relation constraint
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-06-30 16:13:57 +07:00
Methapon2001
b4470d9a0a Merge branch 'develop' 2025-06-30 15:38:22 +07:00
Methapon2001
a69962db48 fix: error validation
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-06-30 15:38:18 +07:00
Methapon2001
96bdb86c73 Merge branch 'develop' 2025-06-30 14:05:46 +07:00
Methapon2001
7bd685ea96 fix: relation order get by id
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-06-30 14:05:41 +07:00
Methapon2001
5262ad2a63 fix: branch order
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-06-30 13:53:07 +07:00
Methapon2001
c430fc3c7a fix: missing zip code when issue doc
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-06-11 10:25:19 +07:00
Methapon2001
47907f61ab Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-05-30 13:07:33 +07:00
Kanjana
e02a29f053 fix : add type null in otherNationality
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-05-30 13:05:33 +07:00
Kanjana
1fe013d69d Merge commit '1896e2385d' into develop 2025-05-30 13:04:38 +07:00
Methapon2001
1896e2385d feat(test): add api branch test 2025-05-23 17:12:06 +07:00
Methapon2001
f7c81641b2 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-05-22 08:52:41 +07:00
Kanjana
791e8b4977 feat : add receiverId in notification Task Failed
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-05-15 13:29:12 +07:00
Methapon2001
125f708ac6 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-05-15 09:12:27 +07:00
Kanjana
0aa20d3728 change detail notification task
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-05-14 14:18:08 +07:00
Kanjana
897ef335b4 add notification expireDate passport, visa
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-05-14 09:28:57 +07:00
Methapon2001
0affb5337f fix: barcode font no display correctly
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-05-13 14:31:37 +07:00
Methapon2001
b276ccddd1 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-05-13 10:06:51 +07:00
Methapon2001
2d0d977617 fix: wrong path
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 8s
2025-05-13 10:03:41 +07:00
Methapon2001
feda84de1c Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-05-07 10:54:41 +07:00
Methapon2001
106343d33d feat: generate barcode
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 7s
2025-05-07 10:54:22 +07:00
Kanjana
2fa50bd7de add value : null in user
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-30 15:36:13 +07:00
Methapon2001
07e5f53be2 fix: request data notification when new 2025-04-30 15:00:43 +07:00
Methapon2001
8a4317c94e Merge branch 'develop' 2025-04-28 16:07:47 +07:00
Methapon2001
f1a774f3bc fix: nullable
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-04-28 16:07:13 +07:00
Kanjana
ce42a6dca6 add null otherNationality
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-04-28 13:43:20 +07:00
Methapon2001
8a3a9e7eb3 Merge branch 'develop' 2025-04-25 17:30:16 +07:00
Kanjana
7fe0512a2f add troubleshooting controller and field otherNationality
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-25 11:59:46 +07:00
Kanjana
5c75c27470 Merge remote-tracking branch 'origin/develop' into develop 2025-04-25 10:52:29 +07:00
Methapon2001
7bc12f00b0 chore: migration
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-24 17:55:21 +07:00
Methapon2001
ffb1ce2d40 feat: allow multiple import nationality for user 2025-04-24 17:55:18 +07:00
Kanjana
5536331984 Merge remote-tracking branch 'chamomind/develop' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-24 15:11:20 +07:00
Kanjana
a57e8d939f async await in getGroup 2025-04-24 15:10:42 +07:00
Methapon2001
92104c05cb feat: filter responsible only
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-04-24 14:53:04 +07:00
Methapon2001
4dbe89f290 feat: response more relation
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-04-24 14:09:57 +07:00
Methapon2001
08b9ddd2e1 feat: reponse relation responsible gropu in request list
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-24 14:07:30 +07:00
Methapon2001
d92e3bc57d feat: add relation to response
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-04-24 13:55:22 +07:00
Kanjana
5594fabb6a Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 7s
2025-04-24 11:40:36 +07:00
Kanjana
109494c6d7 add responsibleGroup in step 2025-04-24 11:40:02 +07:00
Methapon2001
afadea2d64 fix: search not work as expected
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-24 10:50:01 +07:00
Kanjana
1d6224da73 add query in keycloak
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-24 10:41:22 +07:00
Methapon2001
d15aa488c1 feat: fallback to 0 if empty
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-04-23 09:03:03 +07:00
Methapon2001
601deffce4 feat: check for type before try parse field 2025-04-23 09:01:38 +07:00
Kanjana
3193403f90 check price where null
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-04-22 16:27:13 +07:00
Kanjana
8b26f91dba getGroup
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-04-22 15:12:24 +07:00
Kanjana
94c7de89eb add group from keycloak
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 7s
2025-04-22 14:02:36 +07:00
Kanjana
3bf2446611 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-22 11:23:36 +07:00
Kanjana
027326a9e4 add check point price 2025-04-22 11:23:12 +07:00
Methapon2001
40e5f495e5 fix: do not check global name conflict
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 12s
2025-04-22 11:20:11 +07:00
Methapon2001
35ec6cc061 chore: migration
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-22 09:47:20 +07:00
Methapon2001
7bd1f57c32 feat: change employee name requirement 2025-04-22 09:47:17 +07:00
Methapon2001
209ef05d3d chore: change endpoint
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-22 09:37:10 +07:00
Kanjana
b90547c622 Merge remote-tracking branch 'chamomind/develop' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-04-21 11:08:54 +07:00
Kanjana
f98371132a add startDate endDate in instition and receipt , codeProductRecieve in taskOrder 2025-04-21 11:07:06 +07:00
Methapon2001
a25968786d chore: update lock file
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-04-18 16:32:05 +07:00
Kanjana
e42b772dcf use await Promise.all in uploadedFile Product
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-04-18 16:23:02 +07:00
Kanjana
05d16f22de add import file product
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 7s
2025-04-18 15:39:02 +07:00
Kanjana
fd7833a592 add endDate,startDate
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-17 17:56:55 +07:00
Kanjana
d52680c23f add startDate, endDate
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 7s
2025-04-17 17:05:19 +07:00
Kanjana
27d3ce6573 change whereDateQuery in branch
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 7s
2025-04-17 16:21:05 +07:00
Kanjana
5147eed15b Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-04-17 13:43:59 +07:00
Kanjana
0aba9f9865 search startDate and endDate 2025-04-17 13:41:22 +07:00
Methapon2001
f2d0c20ece feat: add endpoint for get same office district
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 8s
2025-04-17 12:56:31 +07:00
Kanjana
ee610c5686 change position
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 11s
2025-04-11 12:50:44 +07:00
Kanjana
62def572de Pull data with CustomerBranch
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 7s
2025-04-11 12:06:11 +07:00
Kanjana
a06d5514fc updata user add employmentOffice
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-11 11:28:24 +07:00
Methapon2001
70245a2b4f Merge remote-tracking branch 'github/10042025-01' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-10 15:52:21 +07:00
Kanjana
7b28ddd2b2 change update
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-04-10 15:38:14 +07:00
Methapon Metanipat
1cd292cdf7
Merge pull request #25 from Frappet/10042025-01
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
add incomplete in RequestData
2025-04-10 15:37:36 +07:00
Kanjana
2e71c86b36 add incomplete in RequestData
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-04-10 15:01:18 +07:00
Methapon2001
08483c162a Merge branch 'patch-08-04-2025' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-10 08:41:50 +07:00
Kanjana
afe54b1a4e add insensitive search 2025-04-09 14:05:05 +07:00
Kanjana
743fde5493 add mode: "insensitive" 2025-04-09 11:54:52 +07:00
Kanjana
7e937333dc add payment attachment
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 11s
2025-04-09 09:51:52 +07:00
Methapon2001
b55998da4a Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 4s
2025-04-08 16:00:40 +07:00
Methapon Metanipat
dab554e401
Merge pull request #24 from Frappet/patch04-04-2025 2025-04-08 15:58:41 +07:00
Kanjana
2307a275f5 change name requestData 2025-04-08 09:27:48 +07:00
Kanjana
709c21082a fix bug in user, employee, institution,requestList 2025-04-08 09:27:48 +07:00
Methapon2001
116fa02bd9 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 3s
2025-04-08 08:49:00 +07:00
Methapon2001
d961ab086b fix: not all user return when pass responsibleDistrictArea
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 15s
2025-04-08 08:48:36 +07:00
Methapon2001
afca10983d Merge branch 'add-request-data-messenger' into develop
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 7s
2025-04-04 10:57:53 +07:00
Kanjana
af4093d439 add router updata-messenger add update defaultMessengerId
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 7s
2025-04-03 18:00:21 +07:00
Methapon2001
c1cd2b9518 Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 5s
2025-04-03 14:53:16 +07:00
Methapon2001
5ce356648d Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s
2025-03-19 09:20:18 +07:00
73 changed files with 5709 additions and 948 deletions

View file

@ -1,33 +1,22 @@
FROM node:23-slim AS base FROM node:20-slim
ENV PNPM_HOME="/pnpm" RUN apt-get update -y \
ENV PATH="$PNPM_HOME:$PATH" && apt-get install -y openssl \
&& npm install -g pnpm \
RUN corepack enable && apt-get clean \
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
RUN pnpm i -g prisma prisma-kysely
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . . COPY . .
FROM base AS deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
RUN pnpm prisma generate
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm prisma generate RUN pnpm prisma generate
RUN pnpm run build RUN pnpm run build
FROM base AS prod COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV NODE_ENV="production" ENTRYPOINT ["/entrypoint.sh"]
COPY --from=deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
COPY --from=base /app/static /app/static
RUN chmod u+x ./entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]

View file

@ -7,6 +7,7 @@
"start": "node ./dist/app.js", "start": "node ./dist/app.js",
"dev": "nodemon", "dev": "nodemon",
"check": "tsc --noEmit", "check": "tsc --noEmit",
"test": "vitest",
"format": "prettier --write .", "format": "prettier --write .",
"debug": "nodemon", "debug": "nodemon",
"build": "tsoa spec-and-routes && tsc", "build": "tsoa spec-and-routes && tsc",
@ -24,35 +25,45 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.9",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.10", "@types/node": "^20.17.10",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@vitest/ui": "^3.1.4",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prisma": "^6.3.0", "prisma": "6.16.2",
"prisma-kysely": "^1.8.0", "prisma-kysely": "^1.8.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.7.2" "typescript": "^5.7.2",
"vitest": "^3.1.4"
}, },
"dependencies": { "dependencies": {
"@elastic/elasticsearch": "^8.17.0", "@elastic/elasticsearch": "^8.17.0",
"@fast-csv/parse": "^5.0.2", "@fast-csv/parse": "^5.0.2",
"@prisma/client": "^6.3.0", "@prisma/client": "6.16.2",
"@scalar/express-api-reference": "^0.4.182", "@scalar/express-api-reference": "^0.4.182",
"@tsoa/runtime": "^6.6.0", "@tsoa/runtime": "^6.6.0",
"barcode": "^0.1.0", "@types/html-to-text": "^9.0.4",
"canvas": "^3.1.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron": "^3.3.1", "cron": "^3.3.1",
"csv-parse": "^6.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dayjs-plugin-utc": "^0.1.2", "dayjs-plugin-utc": "^0.1.2",
"docx-templates": "^4.13.0", "docx-templates": "^4.13.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"exceljs": "^4.4.0",
"express": "^4.21.2", "express": "^4.21.2",
"fast-jwt": "^5.0.5", "fast-jwt": "^5.0.5",
"html-to-text": "^9.0.5",
"jsbarcode": "^3.11.6",
"json-2-csv": "^5.5.8", "json-2-csv": "^5.5.8",
"kysely": "^0.27.5", "kysely": "^0.27.5",
"minio": "^8.0.2", "minio": "^8.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.2",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
"pnpm": "^10.18.3",
"prisma-extension-kysely": "^3.0.0", "prisma-extension-kysely": "^3.0.0",
"promise.any": "^2.0.6", "promise.any": "^2.0.6",
"thai-baht-text": "^2.0.5", "thai-baht-text": "^2.0.5",

1782
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "RequestData" ADD COLUMN "defaultMessengerId" TEXT;
-- AddForeignKey
ALTER TABLE "RequestData" ADD CONSTRAINT "RequestData_defaultMessengerId_fkey" FOREIGN KEY ("defaultMessengerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,29 @@
-- AlterTable
ALTER TABLE "Employee" ALTER COLUMN "firstName" DROP NOT NULL,
ALTER COLUMN "lastName" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Institution" ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactName" TEXT,
ADD COLUMN "contactTel" TEXT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "agencyStatus" TEXT,
ADD COLUMN "remark" TEXT;
-- CreateTable
CREATE TABLE "InstitutionBank" (
"id" TEXT NOT NULL,
"bankName" TEXT NOT NULL,
"bankBranch" TEXT NOT NULL,
"accountName" TEXT NOT NULL,
"accountNumber" TEXT NOT NULL,
"accountType" TEXT NOT NULL,
"currentlyUse" BOOLEAN NOT NULL,
"institutionId" TEXT NOT NULL,
CONSTRAINT "InstitutionBank_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "InstitutionBank" ADD CONSTRAINT "InstitutionBank_institutionId_fkey" FOREIGN KEY ("institutionId") REFERENCES "Institution"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Employee" ALTER COLUMN "dateOfBirth" DROP NOT NULL;

View file

@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "InstitutionBank" DROP CONSTRAINT "InstitutionBank_institutionId_fkey";
-- AddForeignKey
ALTER TABLE "InstitutionBank" ADD CONSTRAINT "InstitutionBank_institutionId_fkey" FOREIGN KEY ("institutionId") REFERENCES "Institution"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "contactName" TEXT,
ADD COLUMN "contactTel" TEXT;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "firstName" DROP NOT NULL,
ALTER COLUMN "lastName" DROP NOT NULL;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TaskOrder" ADD COLUMN "codeProductReceived" TEXT;

View file

@ -0,0 +1,18 @@
-- AlterTable
ALTER TABLE "Institution" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedByUserId" TEXT;
-- AddForeignKey
ALTER TABLE "Institution" ADD CONSTRAINT "Institution_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Institution" ADD CONSTRAINT "Institution_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Employee" ALTER COLUMN "lastNameEN" DROP NOT NULL;

View file

@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "WorkflowTemplateStepGroup" (
"id" TEXT NOT NULL,
"group" TEXT NOT NULL,
"workflowTemplateStepId" TEXT NOT NULL,
CONSTRAINT "WorkflowTemplateStepGroup_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "WorkflowTemplateStepGroup" ADD CONSTRAINT "WorkflowTemplateStepGroup_workflowTemplateStepId_fkey" FOREIGN KEY ("workflowTemplateStepId") REFERENCES "WorkflowTemplateStep"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,20 @@
/*
Warnings:
- You are about to drop the column `importNationality` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "importNationality";
-- CreateTable
CREATE TABLE "UserImportNationality" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "UserImportNationality_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "UserImportNationality" ADD CONSTRAINT "UserImportNationality_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Employee" ADD COLUMN "otherNationality" TEXT;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EmployeePassport" ADD COLUMN "otherNationality" TEXT;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "QuotationWorker" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View file

@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "UserImportNationality" DROP CONSTRAINT "UserImportNationality_userId_fkey";
-- AddForeignKey
ALTER TABLE "UserImportNationality" ADD CONSTRAINT "UserImportNationality_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Quotation" ADD COLUMN "sellerId" TEXT;
-- AddForeignKey
ALTER TABLE "Quotation" ADD CONSTRAINT "Quotation_sellerId_fkey" FOREIGN KEY ("sellerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,36 @@
/*
Warnings:
- You are about to drop the column `businessType` on the `CustomerBranch` table. All the data in the column will be lost.
- You are about to drop the column `customerName` on the `CustomerBranch` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "CustomerBranch" DROP COLUMN "businessType",
DROP COLUMN "customerName",
ADD COLUMN "businessTypeId" TEXT;
-- AlterTable
ALTER TABLE "EmployeeVisa" ADD COLUMN "reportDate" DATE;
-- CreateTable
CREATE TABLE "BusinessType" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameEN" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdByUserId" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedByUserId" TEXT,
CONSTRAINT "BusinessType_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "CustomerBranch" ADD CONSTRAINT "CustomerBranch_businessTypeId_fkey" FOREIGN KEY ("businessTypeId") REFERENCES "BusinessType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BusinessType" ADD CONSTRAINT "BusinessType_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BusinessType" ADD CONSTRAINT "BusinessType_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "addressForeign" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "districtText" TEXT,
ADD COLUMN "provinceText" TEXT,
ADD COLUMN "subDistrictText" TEXT,
ADD COLUMN "zipCodeText" TEXT;

View file

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "districtTextEN" TEXT,
ADD COLUMN "provinceTextEN" TEXT,
ADD COLUMN "subDistrictTextEN" TEXT;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RequestWorkStepStatus" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View file

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "account" TEXT,
ADD COLUMN "channel" TEXT,
ADD COLUMN "reference" TEXT;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "public"."Product" ADD COLUMN "flowAccountProductIdAgentPrice" TEXT,
ADD COLUMN "flowAccountProductIdSellPrice" TEXT;

View file

@ -366,16 +366,24 @@ enum UserType {
AGENCY AGENCY
} }
model UserImportNationality {
id String @id @default(cuid())
name String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
code String? code String?
namePrefix String? namePrefix String?
firstName String firstName String?
firstNameEN String firstNameEN String
middleName String? middleName String?
middleNameEN String? middleNameEN String?
lastName String lastName String?
lastNameEN String lastNameEN String
username String username String
gender String gender String
@ -390,14 +398,24 @@ model User {
street String? street String?
streetEN String? streetEN String?
province Province? @relation(fields: [provinceId], references: [id], onDelete: SetNull) addressForeign Boolean @default(false)
provinceId String?
district District? @relation(fields: [districtId], references: [id], onDelete: SetNull) provinceText String?
districtId String? provinceTextEN String?
province Province? @relation(fields: [provinceId], references: [id], onDelete: SetNull)
provinceId String?
subDistrict SubDistrict? @relation(fields: [subDistrictId], references: [id], onDelete: SetNull) districtText String?
subDistrictId String? districtTextEN String?
district District? @relation(fields: [districtId], references: [id], onDelete: SetNull)
districtId String?
subDistrictText String?
subDistrictTextEN String?
subDistrict SubDistrict? @relation(fields: [subDistrictId], references: [id], onDelete: SetNull)
subDistrictId String?
zipCodeText String?
email String email String
telephoneNo String telephoneNo String
@ -424,7 +442,7 @@ model User {
licenseExpireDate DateTime? @db.Date licenseExpireDate DateTime? @db.Date
sourceNationality String? sourceNationality String?
importNationality String? importNationality UserImportNationality[]
trainingPlace String? trainingPlace String?
responsibleArea UserResponsibleArea[] responsibleArea UserResponsibleArea[]
@ -484,15 +502,28 @@ model User {
flowCreated WorkflowTemplate[] @relation("FlowCreatedByUser") flowCreated WorkflowTemplate[] @relation("FlowCreatedByUser")
flowUpdated WorkflowTemplate[] @relation("FlowUpdatedByUser") flowUpdated WorkflowTemplate[] @relation("FlowUpdatedByUser")
invoiceCreated Invoice[] invoiceCreated Invoice[]
paymentCreated Payment[] paymentCreated Payment[] @relation("PaymentCreatedByUser")
paymentUpdated Payment[] @relation("PaymentUpdatedByUser")
notificationReceive Notification[] @relation("NotificationReceiver") notificationReceive Notification[] @relation("NotificationReceiver")
notificationRead Notification[] @relation("NotificationRead") notificationRead Notification[] @relation("NotificationRead")
notificationDelete Notification[] @relation("NotificationDelete") notificationDelete Notification[] @relation("NotificationDelete")
taskOrderCreated TaskOrder[] @relation("TaskOrderCreatedByUser") taskOrderCreated TaskOrder[] @relation("TaskOrderCreatedByUser")
creditNoteCreated CreditNote[] @relation("CreditNoteCreatedByUser") creditNoteCreated CreditNote[] @relation("CreditNoteCreatedByUser")
institutionCreated Institution[] @relation("InstitutionCreatedByUser")
institutionUpdated Institution[] @relation("InstitutionUpdatedByUser")
businessTypeCreated BusinessType[] @relation("BusinessTypeCreatedByUser")
businessTypeUpdated BusinessType[] @relation("BusinessTypeUpdatedByUser")
requestWorkStepStatus RequestWorkStepStatus[] requestWorkStepStatus RequestWorkStepStatus[]
userTask UserTask[] userTask UserTask[]
requestData RequestData[]
remark String?
agencyStatus String?
contactName String?
contactTel String?
quotation Quotation[]
} }
model UserResponsibleArea { model UserResponsibleArea {
@ -531,10 +562,9 @@ model Customer {
} }
model CustomerBranch { model CustomerBranch {
id String @id @default(cuid()) id String @id @default(cuid())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
customerId String customerId String
customerName String?
code String code String
codeCustomer String codeCustomer String
@ -596,7 +626,8 @@ model CustomerBranch {
agentUser User? @relation(fields: [agentUserId], references: [id], onDelete: SetNull) agentUser User? @relation(fields: [agentUserId], references: [id], onDelete: SetNull)
// NOTE: Business // NOTE: Business
businessType String businessTypeId String?
businessType BusinessType? @relation(fields: [businessTypeId], references: [id], onDelete: SetNull)
jobPosition String jobPosition String
jobDescription String jobDescription String
payDate String payDate String
@ -755,6 +786,21 @@ model CustomerBranchVatRegis {
customerBranch CustomerBranch @relation(fields: [customerBranchId], references: [id], onDelete: Cascade) customerBranch CustomerBranch @relation(fields: [customerBranchId], references: [id], onDelete: Cascade)
} }
model BusinessType {
id String @id @default(cuid())
name String
nameEN String
createdAt DateTime @default(now())
createdBy User? @relation(name: "BusinessTypeCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "BusinessTypeUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
customerBranch CustomerBranch[]
}
model Employee { model Employee {
id String @id @default(cuid()) id String @id @default(cuid())
@ -762,16 +808,17 @@ model Employee {
nrcNo String? nrcNo String?
namePrefix String? namePrefix String?
firstName String firstName String?
firstNameEN String firstNameEN String
middleName String? middleName String?
middleNameEN String? middleNameEN String?
lastName String lastName String?
lastNameEN String lastNameEN String?
dateOfBirth DateTime @db.Date dateOfBirth DateTime? @db.Date
gender String gender String
nationality String nationality String
otherNationality String?
address String? address String?
addressEN String? addressEN String?
@ -846,18 +893,19 @@ model EmployeePassport {
issuePlace String issuePlace String
previousPassportRef String? previousPassportRef String?
workerStatus String? workerStatus String?
nationality String? nationality String?
namePrefix String? otherNationality String?
firstName String? namePrefix String?
firstNameEN String? firstName String?
middleName String? firstNameEN String?
middleNameEN String? middleName String?
lastName String? middleNameEN String?
lastNameEN String? lastName String?
gender String? lastNameEN String?
birthDate String? gender String?
birthCountry String? birthDate String?
birthCountry String?
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
employeeId String employeeId String
@ -874,8 +922,9 @@ model EmployeeVisa {
entryCount Int entryCount Int
issueCountry String issueCountry String
issuePlace String issuePlace String
issueDate DateTime @db.Date issueDate DateTime @db.Date
expireDate DateTime @db.Date expireDate DateTime @db.Date
reportDate DateTime? @db.Date
mrz String? mrz String?
remark String? remark String?
@ -1003,6 +1052,32 @@ model Institution {
selectedImage String? selectedImage String?
taskOrder TaskOrder[] taskOrder TaskOrder[]
contactName String?
contactEmail String?
contactTel String?
createdAt DateTime @default(now())
createdBy User? @relation(name: "InstitutionCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @default(now()) @updatedAt
updatedBy User? @relation(name: "InstitutionUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
bank InstitutionBank[]
}
model InstitutionBank {
id String @id @default(cuid())
bankName String
bankBranch String
accountName String
accountNumber String
accountType String
currentlyUse Boolean
institution Institution @relation(fields: [institutionId], references: [id], onDelete: Cascade)
institutionId String
} }
model Property { model Property {
@ -1053,6 +1128,15 @@ model WorkflowTemplateStepInstitution {
workflowTemplateStepId String workflowTemplateStepId String
} }
model WorkflowTemplateStepGroup {
id String @id @default(cuid())
group String
workflowTemplateStep WorkflowTemplateStep @relation(fields: [workflowTemplateStepId], references: [id], onDelete: Cascade)
workflowTemplateStepId String
}
model WorkflowTemplateStep { model WorkflowTemplateStep {
id String @id @default(cuid()) id String @id @default(cuid())
@ -1063,6 +1147,7 @@ model WorkflowTemplateStep {
value WorkflowTemplateStepValue[] // NOTE: For enum or options type value WorkflowTemplateStepValue[] // NOTE: For enum or options type
responsiblePerson WorkflowTemplateStepUser[] responsiblePerson WorkflowTemplateStepUser[]
responsibleInstitution WorkflowTemplateStepInstitution[] responsibleInstitution WorkflowTemplateStepInstitution[]
responsibleGroup WorkflowTemplateStepGroup[]
messengerByArea Boolean @default(false) messengerByArea Boolean @default(false)
attributes Json? attributes Json?
@ -1158,6 +1243,9 @@ model Product {
productGroup ProductGroup @relation(fields: [productGroupId], references: [id], onDelete: Cascade) productGroup ProductGroup @relation(fields: [productGroupId], references: [id], onDelete: Cascade)
productGroupId String productGroupId String
flowAccountProductIdSellPrice String?
flowAccountProductIdAgentPrice String?
workProduct WorkProduct[] workProduct WorkProduct[]
quotationProductServiceList QuotationProductServiceList[] quotationProductServiceList QuotationProductServiceList[]
taskProduct TaskProduct[] taskProduct TaskProduct[]
@ -1330,6 +1418,9 @@ model Quotation {
invoice Invoice[] invoice Invoice[]
creditNote CreditNote[] creditNote CreditNote[]
seller User? @relation(fields: [sellerId], references: [id], onDelete: Cascade)
sellerId String?
} }
model QuotationPaySplit { model QuotationPaySplit {
@ -1354,6 +1445,9 @@ model QuotationWorker {
employeeId String employeeId String
quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade) quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade)
quotationId String quotationId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
} }
model QuotationProductServiceList { model QuotationProductServiceList {
@ -1433,12 +1527,19 @@ model Payment {
paymentStatus PaymentStatus paymentStatus PaymentStatus
amount Float amount Float
date DateTime? date DateTime?
channel String?
account String?
reference String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull) createdBy User? @relation(name: "PaymentCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String? createdByUserId String?
updatedAt DateTime @default(now()) @updatedAt
updatedBy User? @relation(name: "PaymentUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
} }
enum RequestDataStatus { enum RequestDataStatus {
@ -1469,6 +1570,9 @@ model RequestData {
flow Json? flow Json?
defaultMessenger User? @relation(fields: [defaultMessengerId], references: [id])
defaultMessengerId String?
requestWork RequestWork[] requestWork RequestWork[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -1514,6 +1618,7 @@ model RequestWork {
model RequestWorkStepStatus { model RequestWorkStepStatus {
step Int step Int
workStatus RequestWorkStatus @default(Pending) workStatus RequestWorkStatus @default(Pending)
updatedAt DateTime @default(now()) @updatedAt
requestWork RequestWork @relation(fields: [requestWorkId], references: [id], onDelete: Cascade) requestWork RequestWork @relation(fields: [requestWorkId], references: [id], onDelete: Cascade)
requestWorkId String requestWorkId String
@ -1588,7 +1693,8 @@ model TaskProduct {
model TaskOrder { model TaskOrder {
id String @id @default(cuid()) id String @id @default(cuid())
code String code String
codeProductReceived String?
taskName String taskName String
taskOrderStatus TaskOrderStatus @default(Pending) taskOrderStatus TaskOrderStatus @default(Pending)

View file

@ -1,4 +1,5 @@
import barcode from "barcode"; import { createCanvas } from "canvas";
import JsBarcode from "jsbarcode";
import createReport from "docx-templates"; import createReport from "docx-templates";
import ThaiBahtText from "thai-baht-text"; import ThaiBahtText from "thai-baht-text";
import { District, Province, SubDistrict } from "@prisma/client"; import { District, Province, SubDistrict } from "@prisma/client";
@ -33,8 +34,14 @@ const quotationData = (id: string) =>
}, },
}, },
customerBranch: { customerBranch: {
omit: {
otpCode: true,
otpExpires: true,
userId: true,
},
include: { include: {
customer: true, customer: true,
businessType: true,
province: true, province: true,
district: true, district: true,
subDistrict: true, subDistrict: true,
@ -111,12 +118,12 @@ export class DocTemplateController extends Controller {
) { ) {
const ret = await edmList( const ret = await edmList(
"file", "file",
templateGroup ? [templateGroup, ...DOCUMENT_PATH] : DOCUMENT_PATH, templateGroup ? [...DOCUMENT_PATH, templateGroup] : DOCUMENT_PATH,
); );
if (ret) return ret.map((v) => v.fileName); if (ret) return ret.map((v) => v.fileName);
} }
return await listFile( return await listFile(
(templateGroup ? [templateGroup, ...DOCUMENT_PATH] : DOCUMENT_PATH).join("/") + "/", (templateGroup ? [...DOCUMENT_PATH, templateGroup] : DOCUMENT_PATH).join("/") + "/",
); );
} }
@ -253,13 +260,23 @@ export class DocTemplateController extends Controller {
thaiBahtText: (input: string | number) => { thaiBahtText: (input: string | number) => {
ThaiBahtText(typeof input === "string" ? input.replaceAll(",", "") : input); ThaiBahtText(typeof input === "string" ? input.replaceAll(",", "") : input);
}, },
barcode: async (data: string) => barcode: async (data: string, width?: number, height?: number) =>
new Promise<string>((resolve, reject) => new Promise<{
barcode("code39", { data, width: 400, height: 100 }).getBase64((err, data) => { width: number;
if (!err) return resolve(data); height: number;
return reject(err); data: string;
}), extension: string;
), }>((resolve) => {
const canvas = createCanvas(400, 100);
JsBarcode(canvas, data);
resolve({
width: width ?? 8,
height: height ?? 3,
data: canvas.toDataURL("image/jpeg").slice("data:image/jpeg;base64".length),
extension: ".jpeg",
});
}),
}, },
}).then(Buffer.from); }).then(Buffer.from);
@ -276,6 +293,7 @@ function replaceEmptyField<T>(data: T): T {
} }
type FullAddress = { type FullAddress = {
addressForeign?: boolean;
address: string; address: string;
addressEN: string; addressEN: string;
moo?: string; moo?: string;
@ -284,8 +302,14 @@ type FullAddress = {
soiEN?: string; soiEN?: string;
street?: string; street?: string;
streetEN?: string; streetEN?: string;
provinceText?: string | null;
provinceTextEN?: string | null;
province?: Province | null; province?: Province | null;
districtText?: string | null;
districtTextEN?: string | null;
district?: District | null; district?: District | null;
subDistrictText?: string | null;
subDistrictTextEN?: string | null;
subDistrict?: SubDistrict | null; subDistrict?: SubDistrict | null;
en?: boolean; en?: boolean;
}; };
@ -319,13 +343,22 @@ function addressFull(addr: FullAddress, lang: "th" | "en" = "en") {
if (addr.soi) fragments.push(`ซอย ${addr.soi},`); if (addr.soi) fragments.push(`ซอย ${addr.soi},`);
if (addr.street) fragments.push(`ถนน${addr.street},`); if (addr.street) fragments.push(`ถนน${addr.street},`);
if (addr.subDistrict) { if (!addr.addressForeign && addr.subDistrict) {
fragments.push(`${addr.province?.id === "10" ? "แขวง" : "ตำบล"}${addr.subDistrict.name},`); fragments.push(`${addr.province?.id === "10" ? "แขวง" : "ตำบล"}${addr.subDistrict.name}`);
} }
if (addr.district) { if (addr.addressForeign && addr.subDistrictText) {
fragments.push(`${addr.province?.id === "10" ? "เขต" : "อำเภอ"}${addr.district.name},`); fragments.push(`ตำบล${addr.subDistrictText}`);
} }
if (addr.province) fragments.push(`จังหวัด${addr.province.name},`);
if (!addr.addressForeign && addr.district) {
fragments.push(`${addr.province?.id === "10" ? "เขต" : "อำเภอ"}${addr.district.name}`);
}
if (addr.addressForeign && addr.districtText) {
fragments.push(`อำเภอ${addr.districtText}`);
}
if (!addr.addressForeign && addr.province) fragments.push(`จังหวัด${addr.province.name}`);
if (addr.addressForeign && addr.provinceText) fragments.push(`จังหวัด${addr.provinceText}`);
break; break;
default: default:
@ -334,14 +367,31 @@ function addressFull(addr: FullAddress, lang: "th" | "en" = "en") {
if (addr.soiEN) fragments.push(`Soi ${addr.soiEN},`); if (addr.soiEN) fragments.push(`Soi ${addr.soiEN},`);
if (addr.streetEN) fragments.push(`${addr.streetEN} Rd.`); if (addr.streetEN) fragments.push(`${addr.streetEN} Rd.`);
if (addr.subDistrict) { if (!addr.addressForeign && addr.subDistrict) {
fragments.push(`${addr.subDistrict.nameEN} sub-district,`); fragments.push(`${addr.subDistrict.nameEN} sub-district,`);
} }
if (addr.district) fragments.push(`${addr.district.nameEN} district,`); if (addr.addressForeign && addr.subDistrictTextEN) {
if (addr.province) fragments.push(`${addr.province.nameEN},`); fragments.push(`${addr.subDistrictTextEN} sub-district,`);
}
if (!addr.addressForeign && addr.district) {
fragments.push(`${addr.district.nameEN} district,`);
}
if (addr.addressForeign && addr.districtTextEN) {
fragments.push(`${addr.districtTextEN} district,`);
}
if (!addr.addressForeign && addr.province) {
fragments.push(`${addr.province.nameEN},`);
}
if (addr.addressForeign && addr.provinceTextEN) {
fragments.push(`${addr.provinceTextEN} district,`);
}
break; break;
} }
if (addr.subDistrict) fragments.push(addr.subDistrict.zipCode);
return fragments.join(" "); return fragments.join(" ");
} }
@ -354,6 +404,9 @@ function gender(text: string, lang: "th" | "en" = "en") {
} }
} }
/**
* @deprecated
*/
function businessType(text: string, lang: "th" | "en" = "en") { function businessType(text: string, lang: "th" | "en" = "en") {
switch (lang) { switch (lang) {
case "th": case "th":

View file

@ -2,6 +2,7 @@ import { Body, Controller, Get, Path, Post, Query, Route, Tags } from "tsoa";
import prisma from "../db"; import prisma from "../db";
import { queryOrNot } from "../utils/relation"; import { queryOrNot } from "../utils/relation";
import { notFoundError } from "../utils/error"; import { notFoundError } from "../utils/error";
import { Prisma } from "@prisma/client";
@Route("/api/v1/employment-office") @Route("/api/v1/employment-office")
@Tags("Employment Office") @Tags("Employment Office")
@ -11,6 +12,39 @@ export class EmploymentOfficeController extends Controller {
return this.getEmploymentOfficeListByCriteria(districtId, query); return this.getEmploymentOfficeListByCriteria(districtId, query);
} }
@Post("list-same-office-area")
async getSameOfficeArea(@Body() body: { districtId: string }) {
const office = await prisma.employmentOffice.findFirst({
include: {
province: {
include: {
district: true,
},
},
district: true,
},
where: {
OR: [
{
province: { district: { some: { id: body.districtId } } },
district: { none: {} },
},
{
district: {
some: { districtId: body.districtId },
},
},
],
},
});
if (!office) return [];
return [
...office.district.map((v) => v.districtId),
...office.province.district.map((v) => v.id),
];
}
@Post("list") @Post("list")
async getEmploymentOfficeListByCriteria( async getEmploymentOfficeListByCriteria(
@Query() districtId?: string, @Query() districtId?: string,
@ -40,11 +74,14 @@ export class EmploymentOfficeController extends Controller {
], ],
[], [],
), ),
...queryOrNot( ...(queryOrNot(
query, query,
[{ name: { contains: query } }, { nameEN: { contains: query } }], [
{ name: { contains: query, mode: "insensitive" } },
{ nameEN: { contains: query, mode: "insensitive" } },
],
[], [],
), ) satisfies Prisma.EmploymentOfficeWhereInput["OR"]),
...queryOrNot(!!body?.id, [{ id: { in: body?.id } }], []), ...queryOrNot(!!body?.id, [{ id: { in: body?.id } }], []),
] ]
: undefined, : undefined,

View file

@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, Path, Post, Route, Security, Tags } from "tsoa"; import { Body, Controller, Delete, Get, Path, Post, Query, Route, Security, Tags } from "tsoa";
import { addUserRoles, listRole, removeUserRoles } from "../services/keycloak"; import { addUserRoles, getGroup, listRole, removeUserRoles } from "../services/keycloak";
@Route("api/v1/keycloak") @Route("api/v1/keycloak")
@Tags("Single-Sign On") @Tags("Single-Sign On")
@ -44,4 +44,13 @@ export class KeycloakController extends Controller {
); );
if (!result) throw new Error("Failed. Cannot remove user's role."); if (!result) throw new Error("Failed. Cannot remove user's role.");
} }
@Get("group")
async getGroup(@Query() query: string = "") {
const querySearch = query === "" ? "q" : `search=${query}`;
const group = await getGroup(querySearch);
if (!Array.isArray(group)) throw new Error("Failed. Cannot get group(s) data from the server.");
return group;
}
} }

View file

@ -36,8 +36,8 @@ export class NotificationController extends Controller {
AND: [ AND: [
{ {
OR: queryOrNot<(typeof where)[]>(query, [ OR: queryOrNot<(typeof where)[]>(query, [
{ title: { contains: query } }, { title: { contains: query, mode: "insensitive" } },
{ detail: { contains: query } }, { detail: { contains: query, mode: "insensitive" } },
]), ]),
}, },
{ {

View file

@ -618,9 +618,22 @@ export class StatsController extends Controller {
startDate = dayjs(startDate).startOf("month").add(1, "month").toDate(); startDate = dayjs(startDate).startOf("month").add(1, "month").toDate();
} }
const invoices = await tx.invoice.findMany({
select: { id: true },
where: {
quotation: {
quotationStatus: { notIn: [QuotationStatus.Canceled] },
registeredBranch: { OR: permissionCondCompany(req.user) },
},
},
});
if (invoices.length === 0) return [];
return await Promise.all( return await Promise.all(
months.map(async (v) => { months.map(async (v) => {
const date = dayjs(v); const date = dayjs(v);
return { return {
month: date.format("MM"), month: date.format("MM"),
year: date.format("YYYY"), year: date.format("YYYY"),
@ -629,11 +642,7 @@ export class StatsController extends Controller {
_sum: { amount: true }, _sum: { amount: true },
where: { where: {
createdAt: { gte: v, lte: date.endOf("month").toDate() }, createdAt: { gte: v, lte: date.endOf("month").toDate() },
invoice: { invoiceId: { in: invoices.map((v) => v.id) },
quotation: {
registeredBranch: { OR: permissionCondCompany(req.user) },
},
},
}, },
by: "paymentStatus", by: "paymentStatus",
}) })

View file

@ -39,6 +39,7 @@ import {
connectOrNot, connectOrNot,
queryOrNot, queryOrNot,
whereAddressQuery, whereAddressQuery,
whereDateQuery,
} from "../utils/relation"; } from "../utils/relation";
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
@ -46,16 +47,20 @@ if (!process.env.MINIO_BUCKET) {
throw Error("Require MinIO bucket."); throw Error("Require MinIO bucket.");
} }
const MANAGE_ROLES = ["system", "head_of_admin"]; const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"executive",
"accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
return MANAGE_ROLES.some((v) => user.roles?.includes(v)); const listAllowed = ["system", "head_of_admin", "admin", "executive", "accountant"];
} return user.roles?.some((v) => listAllowed.includes(v)) || false;
function globalAllowView(user: RequestWithUser["user"]) {
return MANAGE_ROLES.concat("head_of_accountant", "head_of_sale").some((v) =>
user.roles?.includes(v),
);
} }
type BranchCreate = { type BranchCreate = {
@ -146,7 +151,7 @@ type BranchUpdate = {
}[]; }[];
}; };
const permissionCond = createPermCondition(globalAllowView); const permissionCond = createPermCondition(globalAllow);
const permissionCheck = createPermCheck(globalAllow); const permissionCheck = createPermCheck(globalAllow);
@Route("api/v1/branch") @Route("api/v1/branch")
@ -250,6 +255,8 @@ export class BranchController extends Controller {
@Query() query: string = "", @Query() query: string = "",
@Query() page: number = 1, @Query() page: number = 1,
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
AND: { AND: {
@ -265,26 +272,27 @@ export class BranchController extends Controller {
}, },
OR: queryOrNot<Prisma.BranchWhereInput[]>(query, [ OR: queryOrNot<Prisma.BranchWhereInput[]>(query, [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ nameEN: { contains: query } }, { nameEN: { contains: query, mode: "insensitive" } },
{ name: { contains: query } }, { name: { contains: query, mode: "insensitive" } },
{ email: { contains: query } }, { email: { contains: query, mode: "insensitive" } },
{ telephoneNo: { contains: query } }, { telephoneNo: { contains: query, mode: "insensitive" } },
...whereAddressQuery(query), ...whereAddressQuery(query),
{ {
branch: { branch: {
some: { some: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ nameEN: { contains: query } }, { nameEN: { contains: query, mode: "insensitive" } },
{ name: { contains: query } }, { name: { contains: query, mode: "insensitive" } },
{ email: { contains: query } }, { email: { contains: query, mode: "insensitive" } },
{ telephoneNo: { contains: query } }, { telephoneNo: { contains: query, mode: "insensitive" } },
...whereAddressQuery(query), ...whereAddressQuery(query),
], ],
}, },
}, },
}, },
]), ]),
...whereDateQuery(startDate, endDate),
} satisfies Prisma.BranchWhereInput; } satisfies Prisma.BranchWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -309,19 +317,20 @@ export class BranchController extends Controller {
where: { where: {
AND: { OR: permissionCond(req.user) }, AND: { OR: permissionCond(req.user) },
OR: [ OR: [
{ nameEN: { contains: query } }, { nameEN: { contains: query, mode: "insensitive" } },
{ name: { contains: query } }, { name: { contains: query, mode: "insensitive" } },
{ email: { contains: query } }, { email: { contains: query, mode: "insensitive" } },
{ telephoneNo: { contains: query } }, { telephoneNo: { contains: query, mode: "insensitive" } },
...whereAddressQuery(query), ...whereAddressQuery(query),
], ],
...whereDateQuery(startDate, endDate),
}, },
include: { include: {
province: true, province: true,
district: true, district: true,
subDistrict: true, subDistrict: true,
}, },
orderBy: { code: "asc" }, orderBy: [{ statusOrder: "asc" }, { code: "asc" }],
} }
: false, : false,
bank: true, bank: true,
@ -365,7 +374,7 @@ export class BranchController extends Controller {
bank: true, bank: true,
contact: includeContact, contact: includeContact,
}, },
orderBy: { code: "asc" }, orderBy: [{ statusOrder: "asc" }, { code: "asc" }],
}, },
bank: true, bank: true,
contact: includeContact, contact: includeContact,
@ -378,6 +387,14 @@ export class BranchController extends Controller {
return record; return record;
} }
@Get("{branchId}/bank")
@Security("keycloak")
async getBranchBankById(@Path() branchId: string) {
return await prisma.branchBank.findMany({
where: { branchId },
});
}
@Post() @Post()
@Security("keycloak", MANAGE_ROLES) @Security("keycloak", MANAGE_ROLES)
async createBranch(@Request() req: RequestWithUser, @Body() body: BranchCreate) { async createBranch(@Request() req: RequestWithUser, @Body() body: BranchCreate) {

View file

@ -18,12 +18,21 @@ import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status"; import HttpStatus from "../interfaces/http-status";
import { RequestWithUser } from "../interfaces/user"; import { RequestWithUser } from "../interfaces/user";
import { branchRelationPermInclude, createPermCheck } from "../services/permission"; import { branchRelationPermInclude, createPermCheck } from "../services/permission";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
const MANAGE_ROLES = ["system", "head_of_admin", "admin", "branch_manager"]; const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"executive",
"accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const listAllowed = ["system", "head_of_admin", "admin"]; const listAllowed = ["system", "head_of_admin", "admin", "executive", "accountant"];
return user.roles?.some((v) => listAllowed.includes(v)) || false; return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
@ -97,6 +106,8 @@ export class UserBranchController extends Controller {
@Query() query: string = "", @Query() query: string = "",
@Query() page: number = 1, @Query() page: number = 1,
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
AND: { AND: {
@ -104,9 +115,10 @@ export class UserBranchController extends Controller {
userId, userId,
}, },
OR: queryOrNot<Prisma.BranchUserWhereInput[]>(query, [ OR: queryOrNot<Prisma.BranchUserWhereInput[]>(query, [
{ branch: { name: { contains: query } } }, { branch: { name: { contains: query, mode: "insensitive" } } },
{ branch: { nameEN: { contains: query } } }, { branch: { nameEN: { contains: query, mode: "insensitive" } } },
]), ]),
...whereDateQuery(startDate, endDate),
} satisfies Prisma.BranchUserWhereInput; } satisfies Prisma.BranchUserWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -150,6 +162,8 @@ export class BranchUserController extends Controller {
@Query() query: string = "", @Query() query: string = "",
@Query() page: number = 1, @Query() page: number = 1,
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
AND: { AND: {
@ -157,13 +171,14 @@ export class BranchUserController extends Controller {
branchId, branchId,
}, },
OR: [ OR: [
{ user: { firstName: { contains: query } } }, { user: { firstName: { contains: query, mode: "insensitive" } } },
{ user: { firstNameEN: { contains: query } } }, { user: { firstNameEN: { contains: query, mode: "insensitive" } } },
{ user: { lastName: { contains: query } } }, { user: { lastName: { contains: query, mode: "insensitive" } } },
{ user: { lastNameEN: { contains: query } } }, { user: { lastNameEN: { contains: query, mode: "insensitive" } } },
{ user: { email: { contains: query } } }, { user: { email: { contains: query, mode: "insensitive" } } },
{ user: { telephoneNo: { contains: query } } }, { user: { telephoneNo: { contains: query, mode: "insensitive" } } },
], ],
...whereDateQuery(startDate, endDate),
} satisfies Prisma.BranchUserWhereInput; } satisfies Prisma.BranchUserWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([

View file

@ -27,6 +27,7 @@ import {
listRole, listRole,
getUserRoles, getUserRoles,
removeUserRoles, removeUserRoles,
getGroupUser,
} from "../services/keycloak"; } from "../services/keycloak";
import { isSystem } from "../utils/keycloak"; import { isSystem } from "../utils/keycloak";
import { import {
@ -51,6 +52,7 @@ import {
connectOrNot, connectOrNot,
queryOrNot, queryOrNot,
whereAddressQuery, whereAddressQuery,
whereDateQuery,
} from "../utils/relation"; } from "../utils/relation";
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
import { retry } from "../utils/func"; import { retry } from "../utils/func";
@ -59,10 +61,17 @@ if (!process.env.MINIO_BUCKET) {
throw Error("Require MinIO bucket."); throw Error("Require MinIO bucket.");
} }
const MANAGE_ROLES = ["system", "head_of_admin", "admin", "branch_manager"]; const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"executive",
"branch_admin",
"branch_manager",
];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const listAllowed = ["system", "head_of_admin"]; const listAllowed = ["system", "head_of_admin", "admin", "executive"];
return user.roles?.some((v) => listAllowed.includes(v)) || false; return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
@ -79,11 +88,11 @@ type UserCreate = {
citizenExpire?: Date | null; citizenExpire?: Date | null;
namePrefix?: string | null; namePrefix?: string | null;
firstName: string; firstName?: string;
firstNameEN: string; firstNameEN: string;
middleName?: string | null; middleName?: string | null;
middleNameEN?: string | null; middleNameEN?: string | null;
lastName: string; lastName?: string;
lastNameEN: string; lastNameEN: string;
gender: string; gender: string;
@ -97,11 +106,12 @@ type UserCreate = {
licenseIssueDate?: Date | null; licenseIssueDate?: Date | null;
licenseExpireDate?: Date | null; licenseExpireDate?: Date | null;
sourceNationality?: string | null; sourceNationality?: string | null;
importNationality?: string | null; importNationality?: string[] | null;
trainingPlace?: string | null; trainingPlace?: string | null;
responsibleArea?: string[] | null; responsibleArea?: string[] | null;
birthDate?: Date | null; birthDate?: Date | null;
addressForeign?: boolean;
address: string; address: string;
addressEN: string; addressEN: string;
soi?: string | null; soi?: string | null;
@ -113,13 +123,26 @@ type UserCreate = {
email: string; email: string;
telephoneNo: string; telephoneNo: string;
subDistrictText?: string | null;
subDistrictTextEN?: string | null;
subDistrictId?: string | null; subDistrictId?: string | null;
districtText?: string | null;
districtTextEN?: string | null;
districtId?: string | null; districtId?: string | null;
provinceText?: string | null;
provinceTextEN?: string | null;
provinceId?: string | null; provinceId?: string | null;
zipCodeText?: string | null;
selectedImage?: string; selectedImage?: string;
branchId: string | string[]; branchId: string | string[];
remark?: string;
agencyStatus?: string;
contactName?: string | null;
contactTel?: string | null;
}; };
type UserUpdate = { type UserUpdate = {
@ -153,11 +176,12 @@ type UserUpdate = {
licenseIssueDate?: Date | null; licenseIssueDate?: Date | null;
licenseExpireDate?: Date | null; licenseExpireDate?: Date | null;
sourceNationality?: string | null; sourceNationality?: string | null;
importNationality?: string | null; importNationality?: string[] | null;
trainingPlace?: string | null; trainingPlace?: string | null;
responsibleArea?: string[] | null; responsibleArea?: string[] | null;
birthDate?: Date | null; birthDate?: Date | null;
addressForeign?: boolean;
address?: string; address?: string;
addressEN?: string; addressEN?: string;
soi?: string | null; soi?: string | null;
@ -171,13 +195,27 @@ type UserUpdate = {
selectedImage?: string; selectedImage?: string;
subDistrictText?: string | null;
subDistrictTextEN?: string | null;
subDistrictId?: string | null; subDistrictId?: string | null;
districtText?: string | null;
districtTextEN?: string | null;
districtId?: string | null; districtId?: string | null;
provinceText?: string | null;
provinceTextEN?: string | null;
provinceId?: string | null; provinceId?: string | null;
zipCodeText?: string | null;
branchId?: string | string[]; branchId?: string | string[];
remark?: string;
agencyStatus?: string;
contactName?: string | null;
contactTel?: string | null;
}; };
const permissionCondCompany = createPermCondition((_) => true);
const permissionCond = createPermCondition(globalAllow); const permissionCond = createPermCondition(globalAllow);
const permissionCheck = createPermCheck(globalAllow); const permissionCheck = createPermCheck(globalAllow);
@ -266,6 +304,8 @@ export class UserController extends Controller {
@Query() status?: Status, @Query() status?: Status,
@Query() responsibleDistrictId?: string, @Query() responsibleDistrictId?: string,
@Query() activeBranchOnly?: boolean, @Query() activeBranchOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
return this.getUserByCriteria( return this.getUserByCriteria(
req, req,
@ -277,6 +317,8 @@ export class UserController extends Controller {
status, status,
responsibleDistrictId, responsibleDistrictId,
activeBranchOnly, activeBranchOnly,
startDate,
endDate,
); );
} }
@ -292,6 +334,8 @@ export class UserController extends Controller {
@Query() status?: Status, @Query() status?: Status,
@Query() responsibleDistrictId?: string, @Query() responsibleDistrictId?: string,
@Query() activeBranchOnly?: boolean, @Query() activeBranchOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
@Body() @Body()
body?: { body?: {
userId?: string[]; userId?: string[];
@ -317,12 +361,12 @@ export class UserController extends Controller {
const where = { const where = {
OR: queryOrNot<Prisma.UserWhereInput[]>(query, [ OR: queryOrNot<Prisma.UserWhereInput[]>(query, [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
{ email: { contains: query } }, { email: { contains: query, mode: "insensitive" } },
{ telephoneNo: { contains: query } }, { telephoneNo: { contains: query, mode: "insensitive" } },
...whereAddressQuery(query), ...whereAddressQuery(query),
]), ]),
AND: { AND: {
@ -348,17 +392,21 @@ export class UserController extends Controller {
: { : {
some: { some: {
branch: { branch: {
OR: permissionCond(req.user, { activeOnly: activeBranchOnly }), OR: responsibleDistrictId
? permissionCondCompany(req.user, { activeOnly: activeBranchOnly }) // NOTE: when pass responsibleDistrictId should see all user not only to current branch
: permissionCond(req.user, { activeOnly: activeBranchOnly }),
}, },
}, },
}, },
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.UserWhereInput; } satisfies Prisma.UserWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
prisma.user.findMany({ prisma.user.findMany({
orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }],
include: { include: {
importNationality: true,
responsibleArea: true, responsibleArea: true,
province: true, province: true,
district: true, district: true,
@ -377,6 +425,7 @@ export class UserController extends Controller {
return { return {
result: result.map((v) => ({ result: result.map((v) => ({
...v, ...v,
importNationality: v.importNationality.map((v) => v.name),
responsibleArea: v.responsibleArea.map((v) => v.area), responsibleArea: v.responsibleArea.map((v) => v.area),
branch: includeBranch ? v.branch.map((a) => a.branch) : undefined, branch: includeBranch ? v.branch.map((a) => a.branch) : undefined,
})), })),
@ -391,6 +440,7 @@ export class UserController extends Controller {
async getUserById(@Path() userId: string) { async getUserById(@Path() userId: string) {
const record = await prisma.user.findFirst({ const record = await prisma.user.findFirst({
include: { include: {
importNationality: true,
province: true, province: true,
district: true, district: true,
subDistrict: true, subDistrict: true,
@ -402,7 +452,11 @@ export class UserController extends Controller {
if (!record) throw notFoundError("User"); if (!record) throw notFoundError("User");
return record; const { importNationality, ...rest } = record;
return Object.assign(rest, {
importNationality: importNationality.map((v) => v.name),
});
} }
@Post() @Post()
@ -468,8 +522,8 @@ export class UserController extends Controller {
} }
const userId = await createUser(username, username, { const userId = await createUser(username, username, {
firstName: body.firstName, firstName: body.firstNameEN,
lastName: body.lastName, lastName: body.lastNameEN,
email: body.email, email: body.email,
requiredActions: ["UPDATE_PASSWORD"], requiredActions: ["UPDATE_PASSWORD"],
enabled: rest.status !== "INACTIVE", enabled: rest.status !== "INACTIVE",
@ -504,6 +558,9 @@ export class UserController extends Controller {
create: rest.responsibleArea.map((v) => ({ area: v })), create: rest.responsibleArea.map((v) => ({ area: v })),
} }
: undefined, : undefined,
importNationality: {
createMany: { data: rest.importNationality?.map((v) => ({ name: v })) || [] },
},
statusOrder: +(rest.status === "INACTIVE"), statusOrder: +(rest.status === "INACTIVE"),
username, username,
userRole: role.name, userRole: role.name,
@ -659,6 +716,7 @@ export class UserController extends Controller {
const record = await prisma.user.update({ const record = await prisma.user.update({
include: { include: {
importNationality: true,
province: true, province: true,
district: true, district: true,
subDistrict: true, subDistrict: true,
@ -673,6 +731,10 @@ export class UserController extends Controller {
create: rest.responsibleArea.map((v) => ({ area: v })), create: rest.responsibleArea.map((v) => ({ area: v })),
} }
: undefined, : undefined,
importNationality: {
deleteMany: {},
createMany: { data: rest.importNationality?.map((v) => ({ name: v })) || [] },
},
statusOrder: +(rest.status === "INACTIVE"), statusOrder: +(rest.status === "INACTIVE"),
userRole, userRole,
province: connectOrDisconnect(provinceId), province: connectOrDisconnect(provinceId),
@ -924,3 +986,17 @@ export class UserSignatureController extends Controller {
await deleteFile(fileLocation.user.signature(userId)); await deleteFile(fileLocation.user.signature(userId));
} }
} }
@Route("api/v1/user/{userId}/group")
@Tags("User")
@Security("keycloak")
export class UserGroupController extends Controller {
@Get()
async getUserGroup(@Path() userId: string) {
const groupUser = await getGroupUser(userId);
if (!Array.isArray(groupUser))
throw new Error("Failed. Cannot get user group(s) data from the server.");
return groupUser;
}
}

View file

@ -23,15 +23,16 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"head_of_sale", "branch_admin",
"sale", "branch_manager",
"branch_accountant",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
type CustomerBranchCitizenPayload = { type CustomerBranchCitizenPayload = {

View file

@ -30,6 +30,7 @@ import {
connectOrNot, connectOrNot,
queryOrNot, queryOrNot,
whereAddressQuery, whereAddressQuery,
whereDateQuery,
} from "../utils/relation"; } from "../utils/relation";
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
import { import {
@ -46,15 +47,18 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
"head_of_sale", "head_of_sale",
"sale", "sale",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const permissionCondCompany = createPermCondition((_) => true); const permissionCondCompany = createPermCondition((_) => true);
@ -83,7 +87,6 @@ export type CustomerBranchCreate = {
authorizedCapital?: string; authorizedCapital?: string;
authorizedName?: string; authorizedName?: string;
authorizedNameEN?: string; authorizedNameEN?: string;
customerName?: string;
telephoneNo: string; telephoneNo: string;
@ -107,7 +110,7 @@ export type CustomerBranchCreate = {
contactName: string; contactName: string;
agentUserId?: string; agentUserId?: string;
businessType: string; businessTypeId?: string;
jobPosition: string; jobPosition: string;
jobDescription: string; jobDescription: string;
payDate: string; payDate: string;
@ -141,7 +144,6 @@ export type CustomerBranchUpdate = {
authorizedCapital?: string; authorizedCapital?: string;
authorizedName?: string; authorizedName?: string;
authorizedNameEN?: string; authorizedNameEN?: string;
customerName?: string;
telephoneNo: string; telephoneNo: string;
@ -165,7 +167,7 @@ export type CustomerBranchUpdate = {
contactName?: string; contactName?: string;
agentUserId?: string; agentUserId?: string;
businessType?: string; businessTypeId?: string;
jobPosition?: string; jobPosition?: string;
jobDescription?: string; jobDescription?: string;
payDate?: string; payDate?: string;
@ -195,18 +197,19 @@ export class CustomerBranchController extends Controller {
@Query() page: number = 1, @Query() page: number = 1,
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() activeRegisBranchOnly?: boolean, @Query() activeRegisBranchOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.CustomerBranchWhereInput[]>(query, [ OR: queryOrNot<Prisma.CustomerBranchWhereInput[]>(query, [
{ customerName: { contains: query } }, { registerName: { contains: query, mode: "insensitive" } },
{ registerName: { contains: query } }, { registerNameEN: { contains: query, mode: "insensitive" } },
{ registerNameEN: { contains: query } }, { email: { contains: query, mode: "insensitive" } },
{ email: { contains: query } }, { code: { contains: query, mode: "insensitive" } },
{ code: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } },
...whereAddressQuery(query), ...whereAddressQuery(query),
]), ]),
AND: { AND: {
@ -229,11 +232,17 @@ export class CustomerBranchController extends Controller {
subDistrict: zipCode ? { zipCode } : undefined, subDistrict: zipCode ? { zipCode } : undefined,
...filterStatus(activeRegisBranchOnly ? Status.ACTIVE : status), ...filterStatus(activeRegisBranchOnly ? Status.ACTIVE : status),
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.CustomerBranchWhereInput; } satisfies Prisma.CustomerBranchWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
prisma.customerBranch.findMany({ prisma.customerBranch.findMany({
orderBy: [{ code: "asc" }, { statusOrder: "asc" }, { createdAt: "asc" }], orderBy: [{ code: "asc" }, { statusOrder: "asc" }, { createdAt: "asc" }],
omit: {
otpCode: true,
otpExpires: true,
userId: true,
},
include: { include: {
customer: includeCustomer, customer: includeCustomer,
province: true, province: true,
@ -242,6 +251,7 @@ export class CustomerBranchController extends Controller {
createdBy: true, createdBy: true,
updatedBy: true, updatedBy: true,
_count: true, _count: true,
businessType: true,
}, },
where, where,
take: pageSize, take: pageSize,
@ -257,6 +267,11 @@ export class CustomerBranchController extends Controller {
@Security("keycloak") @Security("keycloak")
async getById(@Path() branchId: string) { async getById(@Path() branchId: string) {
const record = await prisma.customerBranch.findFirst({ const record = await prisma.customerBranch.findFirst({
omit: {
otpCode: true,
otpExpires: true,
userId: true,
},
include: { include: {
customer: true, customer: true,
province: true, province: true,
@ -264,6 +279,7 @@ export class CustomerBranchController extends Controller {
subDistrict: true, subDistrict: true,
createdBy: true, createdBy: true,
updatedBy: true, updatedBy: true,
businessType: true,
}, },
where: { id: branchId }, where: { id: branchId },
}); });
@ -285,13 +301,15 @@ export class CustomerBranchController extends Controller {
@Query() visa?: boolean, @Query() visa?: boolean,
@Query() page: number = 1, @Query() page: number = 1,
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.EmployeeWhereInput[]>(query, [ OR: queryOrNot<Prisma.EmployeeWhereInput[]>(query, [
{ firstName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
...whereAddressQuery(query), ...whereAddressQuery(query),
]), ]),
AND: { AND: {
@ -300,6 +318,7 @@ export class CustomerBranchController extends Controller {
subDistrict: zipCode ? { zipCode } : undefined, subDistrict: zipCode ? { zipCode } : undefined,
gender, gender,
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.EmployeeWhereInput; } satisfies Prisma.EmployeeWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -343,6 +362,11 @@ export class CustomerBranchController extends Controller {
include: branchRelationPermInclude(req.user), include: branchRelationPermInclude(req.user),
}, },
branch: { branch: {
omit: {
otpCode: true,
otpExpires: true,
userId: true,
},
take: 1, take: 1,
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}, },
@ -371,7 +395,15 @@ export class CustomerBranchController extends Controller {
(v) => (v.headOffice || v).code, (v) => (v.headOffice || v).code,
); );
const { provinceId, districtId, subDistrictId, customerId, agentUserId, ...rest } = body; const {
provinceId,
districtId,
subDistrictId,
customerId,
agentUserId,
businessTypeId,
...rest
} = body;
const record = await prisma.$transaction( const record = await prisma.$transaction(
async (tx) => { async (tx) => {
@ -414,6 +446,7 @@ export class CustomerBranchController extends Controller {
subDistrict: true, subDistrict: true,
createdBy: true, createdBy: true,
updatedBy: true, updatedBy: true,
businessType: true,
}, },
data: { data: {
...rest, ...rest,
@ -425,6 +458,7 @@ export class CustomerBranchController extends Controller {
province: connectOrNot(provinceId), province: connectOrNot(provinceId),
district: connectOrNot(districtId), district: connectOrNot(districtId),
subDistrict: connectOrNot(subDistrictId), subDistrict: connectOrNot(subDistrictId),
businessType: connectOrNot(businessTypeId),
createdBy: { connect: { id: req.user.sub } }, createdBy: { connect: { id: req.user.sub } },
updatedBy: { connect: { id: req.user.sub } }, updatedBy: { connect: { id: req.user.sub } },
}, },
@ -455,6 +489,7 @@ export class CustomerBranchController extends Controller {
}, },
}, },
}, },
businessType: true,
}, },
}); });
@ -499,7 +534,15 @@ export class CustomerBranchController extends Controller {
await permissionCheck(req.user, customer.registeredBranch); await permissionCheck(req.user, customer.registeredBranch);
} }
const { provinceId, districtId, subDistrictId, customerId, agentUserId, ...rest } = body; const {
provinceId,
districtId,
subDistrictId,
customerId,
agentUserId,
businessTypeId,
...rest
} = body;
return await prisma.customerBranch.update({ return await prisma.customerBranch.update({
where: { id: branchId }, where: { id: branchId },
@ -509,6 +552,7 @@ export class CustomerBranchController extends Controller {
subDistrict: true, subDistrict: true,
createdBy: true, createdBy: true,
updatedBy: true, updatedBy: true,
businessType: true,
}, },
data: { data: {
...rest, ...rest,
@ -518,6 +562,7 @@ export class CustomerBranchController extends Controller {
province: connectOrDisconnect(provinceId), province: connectOrDisconnect(provinceId),
district: connectOrDisconnect(districtId), district: connectOrDisconnect(districtId),
subDistrict: connectOrDisconnect(subDistrictId), subDistrict: connectOrDisconnect(subDistrictId),
businessType: connectOrNot(businessTypeId),
updatedBy: { connect: { id: req.user.sub } }, updatedBy: { connect: { id: req.user.sub } },
}, },
}); });
@ -536,6 +581,7 @@ export class CustomerBranchController extends Controller {
}, },
}, },
}, },
businessType: true,
}, },
}); });
@ -588,10 +634,11 @@ export class CustomerBranchFileController extends Controller {
}, },
}, },
}, },
businessType: true,
}, },
}); });
if (!data) throw notFoundError("Customer Branch"); if (!data) throw notFoundError("Customer Branch");
await permissionCheck(user, data.customer.registeredBranch); await permissionCheckCompany(user, data.customer.registeredBranch);
} }
@Get("attachment") @Get("attachment")

View file

@ -36,21 +36,25 @@ import {
setFile, setFile,
} from "../utils/minio"; } from "../utils/minio";
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
import { connectOrNot, queryOrNot } from "../utils/relation"; import { connectOrNot, queryOrNot, whereDateQuery } from "../utils/relation";
import { json2csv } from "json-2-csv";
const MANAGE_ROLES = [ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
"head_of_sale", "head_of_sale",
"sale", "sale",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const permissionCondCompany = createPermCondition((_) => true); const permissionCondCompany = createPermCondition((_) => true);
@ -82,7 +86,6 @@ export type CustomerCreate = {
authorizedCapital?: string; authorizedCapital?: string;
authorizedName?: string; authorizedName?: string;
authorizedNameEN?: string; authorizedNameEN?: string;
customerName?: string;
telephoneNo: string; telephoneNo: string;
@ -106,7 +109,7 @@ export type CustomerCreate = {
contactName: string; contactName: string;
agentUserId?: string; agentUserId?: string;
businessType: string; businessTypeId?: string | null;
jobPosition: string; jobPosition: string;
jobDescription: string; jobDescription: string;
payDate: string; payDate: string;
@ -165,17 +168,22 @@ export class CustomerController extends Controller {
@Query() includeBranch: boolean = false, @Query() includeBranch: boolean = false,
@Query() company: boolean = false, @Query() company: boolean = false,
@Query() activeBranchOnly?: boolean, @Query() activeBranchOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
@Query() businessTypeId?: string,
@Query() provinceId?: string,
@Query() districtId?: string,
@Query() subDistrictId?: string,
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.CustomerWhereInput[]>(query, [ OR: queryOrNot<Prisma.CustomerWhereInput[]>(query, [
{ branch: { some: { namePrefix: { contains: query } } } }, { branch: { some: { namePrefix: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { customerName: { contains: query } } } }, { branch: { some: { registerName: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { registerName: { contains: query } } } }, { branch: { some: { registerNameEN: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { registerNameEN: { contains: query } } } }, { branch: { some: { firstName: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { firstName: { contains: query } } } }, { branch: { some: { firstNameEN: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { firstNameEN: { contains: query } } } }, { branch: { some: { lastName: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { lastName: { contains: query } } } }, { branch: { some: { lastNameEN: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { lastNameEN: { contains: query } } } },
]), ]),
AND: { AND: {
customerType, customerType,
@ -188,6 +196,36 @@ export class CustomerController extends Controller {
: permissionCond(req.user, { activeOnly: activeBranchOnly }), : permissionCond(req.user, { activeOnly: activeBranchOnly }),
}, },
}, },
branch: {
some: {
AND: [
businessTypeId
? {
OR: [{ businessType: { id: businessTypeId } }],
}
: {},
provinceId
? {
OR: [{ province: { id: provinceId } }],
}
: {},
districtId
? {
OR: [{ district: { id: districtId } }],
}
: {},
subDistrictId
? {
OR: [{ subDistrict: { id: subDistrictId } }],
}
: {},
],
},
},
...whereDateQuery(startDate, endDate),
} satisfies Prisma.CustomerWhereInput; } satisfies Prisma.CustomerWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -197,10 +235,16 @@ export class CustomerController extends Controller {
branch: includeBranch branch: includeBranch
? { ? {
include: { include: {
businessType: true,
province: true, province: true,
district: true, district: true,
subDistrict: true, subDistrict: true,
}, },
omit: {
otpCode: true,
otpExpires: true,
userId: true,
},
orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }],
} }
: { : {
@ -209,11 +253,17 @@ export class CustomerController extends Controller {
district: true, district: true,
subDistrict: true, subDistrict: true,
}, },
omit: {
otpCode: true,
otpExpires: true,
userId: true,
},
take: 1, take: 1,
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}, },
createdBy: true, createdBy: true,
updatedBy: true, updatedBy: true,
// businessType:true
}, },
orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }],
where, where,
@ -238,6 +288,11 @@ export class CustomerController extends Controller {
district: true, district: true,
subDistrict: true, subDistrict: true,
}, },
omit: {
otpCode: true,
otpExpires: true,
userId: true,
},
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}, },
createdBy: true, createdBy: true,
@ -309,6 +364,11 @@ export class CustomerController extends Controller {
district: true, district: true,
subDistrict: true, subDistrict: true,
}, },
omit: {
otpCode: true,
otpExpires: true,
userId: true,
},
}, },
createdBy: true, createdBy: true,
updatedBy: true, updatedBy: true,
@ -320,6 +380,8 @@ export class CustomerController extends Controller {
...v, ...v,
code: `${runningKey.replace(`CUSTOMER_BRANCH_${company}_`, "")}-${`${last.value - branch.length + i}`.padStart(2, "0")}`, code: `${runningKey.replace(`CUSTOMER_BRANCH_${company}_`, "")}-${`${last.value - branch.length + i}`.padStart(2, "0")}`,
codeCustomer: runningKey.replace(`CUSTOMER_BRANCH_${company}_`, ""), codeCustomer: runningKey.replace(`CUSTOMER_BRANCH_${company}_`, ""),
businessType: connectOrNot(v.businessTypeId),
businessTypeId: undefined,
agentUser: connectOrNot(v.agentUserId), agentUser: connectOrNot(v.agentUserId),
agentUserId: undefined, agentUserId: undefined,
province: connectOrNot(v.provinceId), province: connectOrNot(v.provinceId),
@ -406,6 +468,11 @@ export class CustomerController extends Controller {
district: true, district: true,
subDistrict: true, subDistrict: true,
}, },
omit: {
otpCode: true,
otpExpires: true,
userId: true,
},
}, },
createdBy: true, createdBy: true,
updatedBy: true, updatedBy: true,
@ -444,7 +511,13 @@ export class CustomerController extends Controller {
await deleteFolder(`customer/${customerId}`); await deleteFolder(`customer/${customerId}`);
const data = await tx.customer.delete({ const data = await tx.customer.delete({
include: { include: {
branch: true, branch: {
omit: {
otpCode: true,
otpExpires: true,
userId: true,
},
},
registeredBranch: { registeredBranch: {
include: { include: {
headOffice: true, headOffice: true,
@ -539,3 +612,52 @@ export class CustomerImageController extends Controller {
await deleteFile(fileLocation.customer.img(customerId, name)); await deleteFile(fileLocation.customer.img(customerId, name));
} }
} }
@Route("api/v1/customer-export")
@Tags("Customer")
export class CustomerExportController extends CustomerController {
@Get()
@Security("keycloak")
async exportCustomer(
@Request() req: RequestWithUser,
@Query() customerType?: CustomerType,
@Query() query: string = "",
@Query() status?: Status,
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() includeBranch: boolean = false,
@Query() company: boolean = false,
@Query() activeBranchOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
@Query() businessTypeId?: string,
@Query() provinceId?: string,
@Query() districtId?: string,
@Query() subDistrictId?: string,
) {
const ret = await this.list(
req,
customerType,
query,
status,
page,
pageSize,
includeBranch,
company,
activeBranchOnly,
startDate,
endDate,
businessTypeId,
provinceId,
districtId,
subDistrictId,
);
this.setHeader("Content-Type", "text/csv");
return json2csv(
ret.result.map((v) => Object.assign(v, { branch: v.branch.at(0) ?? null })),
{ useDateIso8601Format: true, expandNestedObjects: true },
);
}
}

View file

@ -23,14 +23,18 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
"head_of_sale", "head_of_sale",
"sale",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
type EmployeeCheckupPayload = { type EmployeeCheckupPayload = {

View file

@ -30,6 +30,7 @@ import {
connectOrNot, connectOrNot,
queryOrNot, queryOrNot,
whereAddressQuery, whereAddressQuery,
whereDateQuery,
} from "../utils/relation"; } from "../utils/relation";
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
import { import {
@ -41,6 +42,7 @@ import {
listFile, listFile,
setFile, setFile,
} from "../utils/minio"; } from "../utils/minio";
import { json2csv } from "json-2-csv";
if (!process.env.MINIO_BUCKET) { if (!process.env.MINIO_BUCKET) {
throw Error("Require MinIO bucket."); throw Error("Require MinIO bucket.");
@ -50,17 +52,23 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
"head_of_sale", "head_of_sale",
"sale",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const permissionCondCompany = createPermCondition((_) => true);
const permissionCond = createPermCondition(globalAllow); const permissionCond = createPermCondition(globalAllow);
const permissionCheckCompany = createPermCheck((_) => true);
const permissionCheck = createPermCheck(globalAllow); const permissionCheck = createPermCheck(globalAllow);
type EmployeeCreate = { type EmployeeCreate = {
@ -70,16 +78,17 @@ type EmployeeCreate = {
nrcNo?: string | null; nrcNo?: string | null;
dateOfBirth: Date; dateOfBirth?: Date | null;
gender: string; gender: string;
nationality: string; nationality: string;
otherNationality?: string | null;
namePrefix?: string | null; namePrefix?: string | null;
firstName: string; firstName?: string;
firstNameEN: string; firstNameEN: string;
middleName?: string | null; middleName?: string | null;
middleNameEN?: string | null; middleNameEN?: string | null;
lastName: string; lastName?: string;
lastNameEN: string; lastNameEN: string;
addressEN: string; addressEN: string;
@ -106,13 +115,14 @@ type EmployeeUpdate = {
nrcNo?: string | null; nrcNo?: string | null;
dateOfBirth?: Date; dateOfBirth?: Date | null;
gender?: string; gender?: string;
nationality?: string; nationality?: string;
otherNationality?: string | null;
namePrefix?: string | null; namePrefix?: string | null;
firstName?: string; firstName?: string;
firstNameEN?: string; firstNameEN: string;
middleName?: string | null; middleName?: string | null;
middleNameEN?: string | null; middleNameEN?: string | null;
lastName?: string; lastName?: string;
@ -141,9 +151,18 @@ type EmployeeUpdate = {
export class EmployeeController extends Controller { export class EmployeeController extends Controller {
@Get("stats") @Get("stats")
@Security("keycloak") @Security("keycloak")
async getEmployeeStats(@Query() customerBranchId?: string) { async getEmployeeStats(@Request() req: RequestWithUser, @Query() customerBranchId?: string) {
return await prisma.employee.count({ return await prisma.employee.count({
where: { customerBranchId }, where: {
customerBranchId,
customerBranch: {
customer: isSystem(req.user)
? undefined
: {
registeredBranch: { OR: permissionCond(req.user) },
},
},
},
}); });
} }
@ -154,6 +173,8 @@ export class EmployeeController extends Controller {
@Query() customerBranchId?: string, @Query() customerBranchId?: string,
@Query() status?: Status, @Query() status?: Status,
@Query() query: string = "", @Query() query: string = "",
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
return await prisma.employee return await prisma.employee
.groupBy({ .groupBy({
@ -163,13 +184,13 @@ export class EmployeeController extends Controller {
OR: queryOrNot<Prisma.EmployeeWhereInput[]>(query, [ OR: queryOrNot<Prisma.EmployeeWhereInput[]>(query, [
{ {
employeePassport: { employeePassport: {
some: { number: { contains: query } }, some: { number: { contains: query, mode: "insensitive" } },
}, },
}, },
{ firstName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
...whereAddressQuery(query), ...whereAddressQuery(query),
]), ]),
AND: { AND: {
@ -183,6 +204,7 @@ export class EmployeeController extends Controller {
}, },
}, },
}, },
...whereDateQuery(startDate, endDate),
}, },
}) })
.then((res) => .then((res) =>
@ -208,6 +230,8 @@ export class EmployeeController extends Controller {
@Query() page: number = 1, @Query() page: number = 1,
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() activeOnly?: boolean, @Query() activeOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
return this.listByCriteria( return this.listByCriteria(
req, req,
@ -222,9 +246,10 @@ export class EmployeeController extends Controller {
page, page,
pageSize, pageSize,
activeOnly, activeOnly,
startDate,
endDate,
); );
} }
@Post("list") @Post("list")
@Security("keycloak") @Security("keycloak")
async listByCriteria( async listByCriteria(
@ -240,6 +265,8 @@ export class EmployeeController extends Controller {
@Query() page: number = 1, @Query() page: number = 1,
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() activeOnly?: boolean, @Query() activeOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
@Body() @Body()
body?: { body?: {
passport?: string[]; passport?: string[];
@ -252,13 +279,13 @@ export class EmployeeController extends Controller {
...(queryOrNot<Prisma.EmployeeWhereInput[]>(query, [ ...(queryOrNot<Prisma.EmployeeWhereInput[]>(query, [
{ {
employeePassport: { employeePassport: {
some: { number: { contains: query } }, some: { number: { contains: query, mode: "insensitive" } },
}, },
}, },
{ firstName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
...whereAddressQuery(query), ...whereAddressQuery(query),
]) ?? []), ]) ?? []),
...(queryOrNot<Prisma.EmployeeWhereInput[]>(!!body, [ ...(queryOrNot<Prisma.EmployeeWhereInput[]>(!!body, [
@ -288,6 +315,7 @@ export class EmployeeController extends Controller {
subDistrict: zipCode ? { zipCode } : undefined, subDistrict: zipCode ? { zipCode } : undefined,
gender, gender,
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.EmployeeWhereInput; } satisfies Prisma.EmployeeWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -364,9 +392,10 @@ export class EmployeeController extends Controller {
}, },
}), }),
]); ]);
if (body.provinceId !== province?.id) throw relationError("Province"); if (!!body.provinceId && body.provinceId !== province?.id) throw relationError("Province");
if (body.districtId !== district?.id) throw relationError("District"); if (!!body.districtId && body.districtId !== district?.id) throw relationError("District");
if (body.subDistrictId !== subDistrict?.id) throw relationError("SubDistrict"); if (!!body.subDistrictId && body.subDistrictId !== subDistrict?.id)
throw relationError("SubDistrict");
if (!customerBranch) throw relationError("Customer Branch"); if (!customerBranch) throw relationError("Customer Branch");
await permissionCheck(req.user, customerBranch.customer.registeredBranch); await permissionCheck(req.user, customerBranch.customer.registeredBranch);
@ -642,7 +671,7 @@ export class EmployeeFileController extends Controller {
}, },
}); });
if (!data) throw notFoundError("Employee"); if (!data) throw notFoundError("Employee");
await permissionCheck(user, data.customerBranch.customer.registeredBranch); await permissionCheckCompany(user, data.customerBranch.customer.registeredBranch);
} }
@Get("image") @Get("image")
@ -898,3 +927,55 @@ export class EmployeeFileController extends Controller {
return await deleteFile(fileLocation.employee.inCountryNotice(employeeId, noticeId)); return await deleteFile(fileLocation.employee.inCountryNotice(employeeId, noticeId));
} }
} }
@Route("api/v1/employee-export")
@Tags("Employee")
export class EmployeeExportController extends EmployeeController {
@Get()
@Security("keycloak")
async exportEmployee(
@Request() req: RequestWithUser,
@Query() zipCode?: string,
@Query() gender?: string,
@Query() status?: Status,
@Query() visa?: boolean,
@Query() passport?: boolean,
@Query() customerId?: string,
@Query() customerBranchId?: string,
@Query() query: string = "",
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() activeOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) {
const ret = await this.listByCriteria(
req,
zipCode,
gender,
status,
visa,
passport,
customerId,
customerBranchId,
query,
page,
pageSize,
activeOnly,
startDate,
endDate,
);
this.setHeader("Content-Type", "text/csv");
return json2csv(
ret.result.map((v) =>
Object.assign(v, {
employeePassport: v.employeePassport?.at(0) ?? null,
employeeVisa: v.employeeVisa?.at(0) ?? null,
}),
),
{ useDateIso8601Format: true },
);
}
}

View file

@ -23,14 +23,18 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
"head_of_sale", "head_of_sale",
"sale",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
type EmployeeOtherInfoPayload = { type EmployeeOtherInfoPayload = {

View file

@ -22,14 +22,18 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
"head_of_sale", "head_of_sale",
"sale",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
type EmployeePassportPayload = { type EmployeePassportPayload = {
@ -43,6 +47,7 @@ type EmployeePassportPayload = {
workerStatus: string; workerStatus: string;
nationality: string; nationality: string;
otherNationality?: string | null;
namePrefix?: string | null; namePrefix?: string | null;
firstName: string; firstName: string;
firstNameEN: string; firstNameEN: string;

View file

@ -22,14 +22,18 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
"head_of_sale", "head_of_sale",
"sale",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
type EmployeeVisaPayload = { type EmployeeVisaPayload = {
@ -40,6 +44,7 @@ type EmployeeVisaPayload = {
issuePlace: string; issuePlace: string;
issueDate: Date; issueDate: Date;
expireDate: Date; expireDate: Date;
reportDate?: Date | null;
mrz?: string | null; mrz?: string | null;
remark?: string | null; remark?: string | null;

View file

@ -22,14 +22,18 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
"head_of_sale", "head_of_sale",
"sale",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
type EmployeeWorkPayload = { type EmployeeWorkPayload = {

View file

@ -24,7 +24,7 @@ import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status"; import HttpStatus from "../interfaces/http-status";
import { notFoundError } from "../utils/error"; import { notFoundError } from "../utils/error";
import { filterStatus } from "../services/prisma"; import { filterStatus } from "../services/prisma";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
type WorkflowPayload = { type WorkflowPayload = {
name: string; name: string;
@ -37,20 +37,37 @@ type WorkflowPayload = {
attributes?: { [key: string]: any }; attributes?: { [key: string]: any };
responsiblePersonId?: string[]; responsiblePersonId?: string[];
responsibleInstitution?: string[]; responsibleInstitution?: string[];
responsibleGroup?: string[];
messengerByArea?: boolean; messengerByArea?: boolean;
}[]; }[];
registeredBranchId?: string; registeredBranchId?: string;
status?: Status; status?: Status;
}; };
const permissionCondCompany = createPermCondition((_) => true); const MANAGE_ROLES = [
const permissionCheckCompany = createPermCheck((_) => true); "system",
"head_of_admin",
"admin",
"executive",
"accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
];
function globalAllow(user: RequestWithUser["user"]) {
const listAllowed = MANAGE_ROLES;
return user.roles?.some((v) => listAllowed.includes(v)) || false;
}
const permissionCondCompany = createPermCondition(globalAllow);
const permissionCheckCompany = createPermCheck(globalAllow);
@Route("api/v1/workflow-template") @Route("api/v1/workflow-template")
@Tags("Workflow") @Tags("Workflow")
@Security("keycloak")
export class FlowTemplateController extends Controller { export class FlowTemplateController extends Controller {
@Get() @Get()
@Security("keycloak")
async getFlowTemplate( async getFlowTemplate(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Query() page: number = 1, @Query() page: number = 1,
@ -58,13 +75,15 @@ export class FlowTemplateController extends Controller {
@Query() status?: Status, @Query() status?: Status,
@Query() query = "", @Query() query = "",
@Query() activeOnly?: boolean, @Query() activeOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: queryOrNot(query, [ OR: queryOrNot(query, [
{ name: { contains: query } }, { name: { contains: query, mode: "insensitive" } },
{ {
step: { step: {
some: { name: { contains: query } }, some: { name: { contains: query, mode: "insensitive" } },
}, },
}, },
]), ]),
@ -74,6 +93,7 @@ export class FlowTemplateController extends Controller {
OR: permissionCondCompany(req.user, { activeOnly: true }), OR: permissionCondCompany(req.user, { activeOnly: true }),
}, },
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.WorkflowTemplateWhereInput; } satisfies Prisma.WorkflowTemplateWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
prisma.workflowTemplate.findMany({ prisma.workflowTemplate.findMany({
@ -86,6 +106,7 @@ export class FlowTemplateController extends Controller {
include: { user: true }, include: { user: true },
}, },
responsibleInstitution: true, responsibleInstitution: true,
responsibleGroup: true,
}, },
orderBy: { order: "asc" }, orderBy: { order: "asc" },
}, },
@ -103,6 +124,7 @@ export class FlowTemplateController extends Controller {
step: r.step.map((v) => ({ step: r.step.map((v) => ({
...v, ...v,
responsibleInstitution: v.responsibleInstitution.map((institution) => institution.group), responsibleInstitution: v.responsibleInstitution.map((institution) => institution.group),
responsibleGroup: v.responsibleGroup.map((group) => group.group),
})), })),
})), })),
page, page,
@ -112,6 +134,7 @@ export class FlowTemplateController extends Controller {
} }
@Get("{templateId}") @Get("{templateId}")
@Security("keycloak")
async getFlowTemplateById(@Request() _req: RequestWithUser, @Path() templateId: string) { async getFlowTemplateById(@Request() _req: RequestWithUser, @Path() templateId: string) {
const record = await prisma.workflowTemplate.findFirst({ const record = await prisma.workflowTemplate.findFirst({
include: { include: {
@ -123,6 +146,7 @@ export class FlowTemplateController extends Controller {
include: { user: true }, include: { user: true },
}, },
responsibleInstitution: true, responsibleInstitution: true,
responsibleGroup: true,
}, },
}, },
}, },
@ -137,11 +161,13 @@ export class FlowTemplateController extends Controller {
step: record.step.map((v) => ({ step: record.step.map((v) => ({
...v, ...v,
responsibleInstitution: v.responsibleInstitution.map((institution) => institution.group), responsibleInstitution: v.responsibleInstitution.map((institution) => institution.group),
responsibleGroup: v.responsibleGroup.map((group) => group.group),
})), })),
}; };
} }
@Post() @Post()
@Security("keycloak", MANAGE_ROLES)
async createFlowTemplate(@Request() req: RequestWithUser, @Body() body: WorkflowPayload) { async createFlowTemplate(@Request() req: RequestWithUser, @Body() body: WorkflowPayload) {
const where = { const where = {
OR: [ OR: [
@ -212,6 +238,9 @@ export class FlowTemplateController extends Controller {
responsibleInstitution: { responsibleInstitution: {
create: v.responsibleInstitution?.map((group) => ({ group })), create: v.responsibleInstitution?.map((group) => ({ group })),
}, },
responsibleGroup: {
create: v.responsibleGroup?.map((group) => ({ group })),
},
})), })),
}, },
}, },
@ -219,6 +248,7 @@ export class FlowTemplateController extends Controller {
} }
@Put("{templateId}") @Put("{templateId}")
@Security("keycloak", MANAGE_ROLES)
async updateFlowTemplate( async updateFlowTemplate(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Path() templateId: string, @Path() templateId: string,
@ -292,6 +322,10 @@ export class FlowTemplateController extends Controller {
deleteMany: {}, deleteMany: {},
create: v.responsibleInstitution?.map((group) => ({ group })), create: v.responsibleInstitution?.map((group) => ({ group })),
}, },
responsibleGroup: {
deleteMany: {},
create: v.responsibleGroup?.map((group) => ({ group })),
},
}, },
})), })),
}, },
@ -300,6 +334,7 @@ export class FlowTemplateController extends Controller {
} }
@Delete("{templateId}") @Delete("{templateId}")
@Security("keycloak", MANAGE_ROLES)
async deleteFlowTemplateById(@Request() req: RequestWithUser, @Path() templateId: string) { async deleteFlowTemplateById(@Request() req: RequestWithUser, @Path() templateId: string) {
const record = await prisma.workflowTemplate.findUnique({ const record = await prisma.workflowTemplate.findUnique({
where: { id: templateId }, where: { id: templateId },

View file

@ -17,7 +17,7 @@ import {
} from "tsoa"; } from "tsoa";
import prisma from "../db"; import prisma from "../db";
import { isUsedError, notFoundError } from "../utils/error"; import { isUsedError, notFoundError } from "../utils/error";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
import { RequestWithUser } from "../interfaces/user"; import { RequestWithUser } from "../interfaces/user";
import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio"; import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio";
import HttpError from "../interfaces/http-error"; import HttpError from "../interfaces/http-error";
@ -44,8 +44,68 @@ type InstitutionPayload = {
provinceId: string; provinceId: string;
selectedImage?: string | null; selectedImage?: string | null;
contactName?: string;
contactEmail?: string;
contactTel?: string;
bank?: {
bankName: string;
bankBranch: string;
accountName: string;
accountNumber: string;
accountType: string;
currentlyUse: boolean;
}[];
}; };
type InstitutionUpdatePayload = {
name: string;
nameEN: string;
code: string;
addressEN: string;
address: string;
soi?: string | null;
soiEN?: string | null;
moo?: string | null;
mooEN?: string | null;
street?: string | null;
streetEN?: string | null;
subDistrictId: string;
districtId: string;
provinceId: string;
selectedImage?: string | null;
contactName?: string;
contactEmail?: string;
contactTel?: string;
bank?: {
id?: string;
bankName: string;
bankBranch: string;
accountName: string;
accountNumber: string;
accountType: string;
currentlyUse: boolean;
}[];
};
const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"executive",
"accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
];
@Route("api/v1/institution") @Route("api/v1/institution")
@Tags("Institution") @Tags("Institution")
export class InstitutionController extends Controller { export class InstitutionController extends Controller {
@ -59,8 +119,19 @@ export class InstitutionController extends Controller {
@Query() status?: Status, @Query() status?: Status,
@Query() activeOnly?: boolean, @Query() activeOnly?: boolean,
@Query() group?: string, @Query() group?: string,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
return this.getInstitutionListByCriteria(query, page, pageSize, status, activeOnly, group); return this.getInstitutionListByCriteria(
query,
page,
pageSize,
status,
activeOnly,
group,
startDate,
endDate,
);
} }
@Post("list") @Post("list")
@ -73,6 +144,8 @@ export class InstitutionController extends Controller {
@Query() status?: Status, @Query() status?: Status,
@Query() activeOnly?: boolean, @Query() activeOnly?: boolean,
@Query() group?: string, @Query() group?: string,
@Query() startDate?: Date,
@Query() endDate?: Date,
@Body() @Body()
body?: { body?: {
group?: string[]; group?: string[];
@ -82,9 +155,10 @@ export class InstitutionController extends Controller {
...filterStatus(activeOnly ? Status.ACTIVE : status), ...filterStatus(activeOnly ? Status.ACTIVE : status),
group: body?.group ? { in: body.group } : group, group: body?.group ? { in: body.group } : group,
OR: queryOrNot<Prisma.InstitutionWhereInput[]>(query, [ OR: queryOrNot<Prisma.InstitutionWhereInput[]>(query, [
{ name: { contains: query } }, { name: { contains: query, mode: "insensitive" } },
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
]), ]),
...whereDateQuery(startDate, endDate),
} satisfies Prisma.InstitutionWhereInput; } satisfies Prisma.InstitutionWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -94,6 +168,7 @@ export class InstitutionController extends Controller {
province: true, province: true,
district: true, district: true,
subDistrict: true, subDistrict: true,
bank: true,
}, },
orderBy: [{ statusOrder: "asc" }, { code: "asc" }], orderBy: [{ statusOrder: "asc" }, { code: "asc" }],
take: pageSize, take: pageSize,
@ -114,19 +189,21 @@ export class InstitutionController extends Controller {
province: true, province: true,
district: true, district: true,
subDistrict: true, subDistrict: true,
bank: true,
}, },
where: { id: institutionId, group }, where: { id: institutionId, group },
}); });
} }
@Post() @Post()
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
@OperationId("createInstitution") @OperationId("createInstitution")
async createInstitution( async createInstitution(
@Body() @Body()
body: InstitutionPayload & { body: InstitutionPayload & {
status?: Status; status?: Status;
}, },
@Request() req: RequestWithUser,
) { ) {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const last = await tx.runningNo.upsert({ const last = await tx.runningNo.upsert({
@ -141,33 +218,78 @@ export class InstitutionController extends Controller {
}); });
return await tx.institution.create({ return await tx.institution.create({
include: {
bank: true,
createdBy: true,
updatedBy: true,
},
data: { data: {
...body, ...body,
code: `${body.code}${last.value.toString().padStart(5, "0")}`, code: `${body.code}${last.value.toString().padStart(5, "0")}`,
group: body.code, group: body.code,
bank: {
createMany: {
data: body.bank ?? [],
},
},
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
}, },
}); });
}); });
} }
@Put("{institutionId}") @Put("{institutionId}")
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
@OperationId("updateInstitution") @OperationId("updateInstitution")
async updateInstitution( async updateInstitution(
@Path() institutionId: string, @Path() institutionId: string,
@Body() @Body()
body: InstitutionPayload & { body: InstitutionUpdatePayload & {
status?: "ACTIVE" | "INACTIVE"; status?: "ACTIVE" | "INACTIVE";
}, },
) { ) {
return await prisma.institution.update({ const { bank } = body;
where: { id: institutionId }, return await prisma.$transaction(async (tx) => {
data: { ...body, statusOrder: +(body.status === "INACTIVE") }, const listDeleted = bank
? await tx.institutionBank.findMany({
where: {
id: { not: { in: bank.flatMap((v) => (!!v.id ? v.id : [])) } },
institutionId,
},
})
: [];
await Promise.all(
listDeleted.map((v) => deleteFile(fileLocation.institution.bank(v.institutionId, v.id))),
);
return await prisma.institution.update({
include: {
bank: true,
},
where: { id: institutionId },
data: {
...body,
statusOrder: +(body.status === "INACTIVE"),
bank: bank
? {
deleteMany:
listDeleted.length > 0 ? { id: { in: listDeleted.map((v) => v.id) } } : undefined,
upsert: bank.map((v) => ({
where: { id: v.id || "" },
create: { ...v, id: undefined },
update: v,
})),
}
: undefined,
},
});
}); });
} }
@Delete("{institutionId}") @Delete("{institutionId}")
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
@OperationId("deleteInstitution") @OperationId("deleteInstitution")
async deleteInstitution(@Path() institutionId: string) { async deleteInstitution(@Path() institutionId: string) {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
@ -185,9 +307,18 @@ export class InstitutionController extends Controller {
throw isUsedError("Institution"); throw isUsedError("Institution");
} }
return await tx.institution.delete({ const data = await tx.institution.delete({
include: {
bank: true,
},
where: { id: institutionId }, where: { id: institutionId },
}); });
await Promise.all([
...data.bank.map((v) => deleteFile(fileLocation.institution.bank(institutionId, v.id))),
]);
return data;
}); });
} }
} }
@ -230,7 +361,7 @@ export class InstitutionFileController extends Controller {
} }
@Put("image/{name}") @Put("image/{name}")
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
async putImage( async putImage(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Path() institutionId: string, @Path() institutionId: string,
@ -244,7 +375,7 @@ export class InstitutionFileController extends Controller {
} }
@Delete("image/{name}") @Delete("image/{name}")
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
async delImage( async delImage(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Path() institutionId: string, @Path() institutionId: string,
@ -274,7 +405,7 @@ export class InstitutionFileController extends Controller {
} }
@Put("attachment/{name}") @Put("attachment/{name}")
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
async putAttachment( async putAttachment(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Path() institutionId: string, @Path() institutionId: string,
@ -285,7 +416,7 @@ export class InstitutionFileController extends Controller {
} }
@Delete("attachment/{name}") @Delete("attachment/{name}")
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
async delAttachment( async delAttachment(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Path() institutionId: string, @Path() institutionId: string,
@ -294,4 +425,49 @@ export class InstitutionFileController extends Controller {
await this.checkPermission(req.user, institutionId); await this.checkPermission(req.user, institutionId);
return await deleteFile(fileLocation.institution.attachment(institutionId, name)); return await deleteFile(fileLocation.institution.attachment(institutionId, name));
} }
@Get("bank-qr/{bankId}")
async getBankImage(
@Request() req: RequestWithUser,
@Path() institutionId: string,
@Path() bankId: string,
) {
return req.res?.redirect(await getFile(fileLocation.institution.bank(institutionId, bankId)));
}
@Head("bank-qr/{bankId}")
async headBankImage(
@Request() req: RequestWithUser,
@Path() institutionId: string,
@Path() bankId: string,
) {
return req.res?.redirect(
await getPresigned("head", fileLocation.institution.bank(institutionId, bankId)),
);
}
@Put("bank-qr/{bankId}")
@Security("keycloak", MANAGE_ROLES)
async putBankImage(
@Request() req: RequestWithUser,
@Path() institutionId: string,
@Path() bankId: string,
) {
if (!req.headers["content-type"]?.startsWith("image/")) {
throw new HttpError(HttpStatus.BAD_REQUEST, "Not a valid image.", "notValidImage");
}
await this.checkPermission(req.user, institutionId);
return req.res?.redirect(await setFile(fileLocation.institution.bank(institutionId, bankId)));
}
@Delete("bank-qr/{bankId}")
@Security("keycloak", MANAGE_ROLES)
async delBankImage(
@Request() req: RequestWithUser,
@Path() institutionId: string,
@Path() bankId: string,
) {
await this.checkPermission(req.user, institutionId);
return await deleteFile(fileLocation.institution.bank(institutionId, bankId));
}
} }

View file

@ -21,6 +21,7 @@ import {
createPermCondition, createPermCondition,
} from "../services/permission"; } from "../services/permission";
import { PaymentStatus } from "../generated/kysely/types"; import { PaymentStatus } from "../generated/kysely/types";
import { whereDateQuery } from "../utils/relation";
type InvoicePayload = { type InvoicePayload = {
quotationId: string; quotationId: string;
@ -28,14 +29,23 @@ type InvoicePayload = {
installmentNo: number[]; installmentNo: number[];
}; };
const MANAGE_ROLES = ["system", "head_of_admin", "admin", "head_of_accountant", "accountant"]; const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"executive",
"accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant"]; const listAllowed = ["system", "head_of_admin", "admin", "executive", "accountant"];
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const permissionCondCompany = createPermCondition((_) => true); const permissionCondCompany = createPermCondition(globalAllow);
const permissionCheck = createPermCheck(globalAllow); const permissionCheck = createPermCheck(globalAllow);
@Route("/api/v1/invoice") @Route("/api/v1/invoice")
@ -95,23 +105,24 @@ export class InvoiceController extends Controller {
@Query() quotationId?: string, @Query() quotationId?: string,
@Query() debitNoteId?: string, @Query() debitNoteId?: string,
@Query() pay?: boolean, @Query() pay?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where: Prisma.InvoiceWhereInput = { const where: Prisma.InvoiceWhereInput = {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ quotation: { workName: { contains: query } } }, { quotation: { workName: { contains: query, mode: "insensitive" } } },
{ {
quotation: { quotation: {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query } }, { registerName: { contains: query, mode: "insensitive" } },
{ registerName: { contains: query } }, { registerNameEN: { contains: query, mode: "insensitive" } },
{ registerNameEN: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } },
], ],
}, },
}, },
@ -132,6 +143,7 @@ export class InvoiceController extends Controller {
OR: permissionCondCompany(req.user), OR: permissionCondCompany(req.user),
}, },
}, },
...whereDateQuery(startDate, endDate),
}; };
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -180,7 +192,7 @@ export class InvoiceController extends Controller {
@Post() @Post()
@OperationId("createInvoice") @OperationId("createInvoice")
@Security("keycloak", MANAGE_ROLES) @Security("keycloak", MANAGE_ROLES.concat(["head_of_sale", "sale"]))
async createInvoice(@Request() req: RequestWithUser, @Body() body: InvoicePayload) { async createInvoice(@Request() req: RequestWithUser, @Body() body: InvoicePayload) {
const [quotation] = await prisma.$transaction([ const [quotation] = await prisma.$transaction([
prisma.quotation.findUnique({ prisma.quotation.findUnique({
@ -225,7 +237,7 @@ export class InvoiceController extends Controller {
title: "ใบแจ้งหนี้ใหม่ / New Invoice", title: "ใบแจ้งหนี้ใหม่ / New Invoice",
detail: "รหัส / code : " + record.code, detail: "รหัส / code : " + record.code,
registeredBranchId: record.registeredBranchId, registeredBranchId: record.registeredBranchId,
groupReceiver: { create: { name: "accountant" } }, groupReceiver: { create: { name: "branch_accountant" } },
}, },
}); });

View file

@ -11,6 +11,7 @@ import {
Security, Security,
Tags, Tags,
Query, Query,
UploadedFile,
} from "tsoa"; } from "tsoa";
import { Prisma, Product, Status } from "@prisma/client"; import { Prisma, Product, Status } from "@prisma/client";
@ -27,20 +28,25 @@ import { isSystem } from "../utils/keycloak";
import { filterStatus } from "../services/prisma"; import { filterStatus } from "../services/prisma";
import { deleteFile, deleteFolder, fileLocation, getFile, listFile, setFile } from "../utils/minio"; import { deleteFile, deleteFolder, fileLocation, getFile, listFile, setFile } from "../utils/minio";
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
import spreadsheet from "../utils/spreadsheet";
import flowAccount from "../services/flowaccount";
import { json2csv } from "json-2-csv";
const MANAGE_ROLES = [ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"head_of_sale", "branch_admin",
"branch_manager",
"branch_accountant",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const permissionCondCompany = createPermCondition((_) => true); const permissionCondCompany = createPermCondition((_) => true);
@ -72,6 +78,7 @@ type ProductCreate = {
type ProductUpdate = { type ProductUpdate = {
status?: "ACTIVE" | "INACTIVE"; status?: "ACTIVE" | "INACTIVE";
code?: string;
name?: string; name?: string;
detail?: string; detail?: string;
process?: number; process?: number;
@ -139,6 +146,8 @@ export class ProductController extends Controller {
@Query() orderField?: keyof Product, @Query() orderField?: keyof Product,
@Query() orderBy?: "asc" | "desc", @Query() orderBy?: "asc" | "desc",
@Query() activeOnly?: boolean, @Query() activeOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
// NOTE: will be used to scope product within product group that is shared between branch but not company when select shared product if user is system // NOTE: will be used to scope product within product group that is shared between branch but not company when select shared product if user is system
const targetGroup = const targetGroup =
@ -154,8 +163,8 @@ export class ProductController extends Controller {
const where = { const where = {
OR: queryOrNot<Prisma.ProductWhereInput[]>(query, [ OR: queryOrNot<Prisma.ProductWhereInput[]>(query, [
{ name: { contains: query } }, { name: { contains: query, mode: "insensitive" } },
{ detail: { contains: query } }, { detail: { contains: query, mode: "insensitive" } },
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
]), ]),
AND: { AND: {
@ -194,6 +203,7 @@ export class ProductController extends Controller {
: []), : []),
], ],
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.ProductWhereInput; } satisfies Prisma.ProductWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -292,13 +302,21 @@ export class ProductController extends Controller {
}, },
update: { value: { increment: 1 } }, update: { value: { increment: 1 } },
}); });
return await prisma.product.create({
const listId = await flowAccount.createProducts(
`${body.code.toLocaleUpperCase()}${last.value.toString().padStart(3, "0")}`,
body,
);
return await tx.product.create({
include: { include: {
createdBy: true, createdBy: true,
updatedBy: true, updatedBy: true,
}, },
data: { data: {
...body, ...body,
flowAccountProductIdAgentPrice: `${listId.data.productIdAgentPrice}`,
flowAccountProductIdSellPrice: `${listId.data.productIdSellPrice}`,
document: body.document document: body.document
? { ? {
createMany: { data: body.document.map((v) => ({ name: v })) }, createMany: { data: body.document.map((v) => ({ name: v })) },
@ -372,6 +390,30 @@ export class ProductController extends Controller {
await permissionCheck(req.user, productGroup.registeredBranch); await permissionCheck(req.user, productGroup.registeredBranch);
} }
if (
product.flowAccountProductIdSellPrice !== null &&
product.flowAccountProductIdAgentPrice !== null
) {
const mergedBody = {
...body,
code: body.code ?? product.code,
price: body.price ?? product.price,
agentPrice: body.agentPrice ?? product.agentPrice,
serviceCharge: body.serviceCharge ?? product.serviceCharge,
vatIncluded: body.vatIncluded ?? product.vatIncluded,
agentPriceVatIncluded: body.agentPriceVatIncluded ?? product.agentPriceVatIncluded,
serviceChargeVatIncluded: body.serviceChargeVatIncluded ?? product.serviceChargeVatIncluded,
};
await flowAccount.editProducts(
product.flowAccountProductIdSellPrice,
product.flowAccountProductIdAgentPrice,
mergedBody,
);
} else {
throw notFoundError("FlowAccountProductId");
}
const record = await prisma.product.update({ const record = await prisma.product.update({
include: { include: {
productGroup: true, productGroup: true,
@ -434,6 +476,18 @@ export class ProductController extends Controller {
if (record.status !== Status.CREATED) throw isUsedError("Product"); if (record.status !== Status.CREATED) throw isUsedError("Product");
if (
record.flowAccountProductIdSellPrice !== null &&
record.flowAccountProductIdAgentPrice !== null
) {
await Promise.all([
flowAccount.deleteProduct(record.flowAccountProductIdSellPrice),
flowAccount.deleteProduct(record.flowAccountProductIdAgentPrice),
]);
} else {
throw notFoundError("FlowAccountProductId");
}
await deleteFolder(fileLocation.product.img(productId)); await deleteFolder(fileLocation.product.img(productId));
return await prisma.product.delete({ return await prisma.product.delete({
@ -444,6 +498,146 @@ export class ProductController extends Controller {
where: { id: productId }, where: { id: productId },
}); });
} }
@Post("import-product")
@Security("keycloak", MANAGE_ROLES)
async importProduct(
@Request() req: RequestWithUser,
@UploadedFile() file: Express.Multer.File,
@Query() productGroupId: string,
) {
if (!file?.buffer) throw notFoundError("File");
const buffer = new Uint8Array(file.buffer).buffer;
const dataFile = await spreadsheet.readExcel(buffer, {
header: true,
worksheet: "Sheet1",
});
let dataName: string[] = [];
const data = dataFile.map((item: any) => {
dataName.push(item.name);
return {
...item,
expenseType:
item.expenseType === "ค่าธรรมเนียม"
? "fee"
: item.expenseType === "ค่าบริการ"
? "serviceFee"
: "processingFee",
shared: item.shared === "ใช่" ? true : false,
price:
typeof item.price === "number"
? item.price
: +parseFloat(item.price?.replace(",", "") || "0").toFixed(6),
calcVat: item.calcVat === "ใช่" ? true : false,
vatIncluded: item.vatIncluded === "รวม" ? true : false,
agentPrice:
typeof item.agentPrice === "number"
? item.agentPrice
: +parseFloat(item.agentPrice?.replace(",", "") || "0").toFixed(6),
agentPriceCalcVat: item.agentPriceCalcVat === "ใช่" ? true : false,
agentPriceVatIncluded: item.agentPriceVatIncluded === "รวม" ? true : false,
serviceCharge:
typeof item.serviceCharge === "number"
? item.serviceCharge
: +parseFloat(item.serviceCharge?.replace(",", "") || "0").toFixed(6),
serviceChargeCalcVat: item.serviceChargeCalcVat === "ใช่" ? true : false,
serviceChargeVatIncluded: item.serviceChargeVatIncluded === "รวม" ? true : false,
};
});
const [productGroup, productSameName] = await prisma.$transaction([
prisma.productGroup.findFirst({
include: {
registeredBranch: {
include: branchRelationPermInclude(req.user),
},
createdBy: true,
updatedBy: true,
},
where: { id: productGroupId },
}),
prisma.product.findMany({
where: {
productGroup: {
id: productGroupId,
registeredBranch: {
OR: permissionCondCompany(req.user),
},
},
name: { in: dataName },
},
}),
]);
if (!productGroup) throw relationError("Product Group");
await permissionCheck(req.user, productGroup.registeredBranch);
let dataProduct: ProductCreate[] = [];
const record = await prisma.$transaction(
async (tx) => {
const branch = productGroup.registeredBranch;
const company = (branch.headOffice || branch).code;
await Promise.all(
data.map(async (item) => {
const dataDuplicate = productSameName.some(
(v) => v.code.slice(0, -3) === item.code.toUpperCase() && v.name === item.name,
);
if (!dataDuplicate) {
const last = await tx.runningNo.upsert({
where: {
key: `PRODUCT_${company}_${item.code.toLocaleUpperCase()}`,
},
create: {
key: `PRODUCT_${company}_${item.code.toLocaleUpperCase()}`,
value: 1,
},
update: { value: { increment: 1 } },
});
dataProduct.push({
...item,
code: `${item.code.toLocaleUpperCase()}${last.value.toString().padStart(3, "0")}`,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
productGroupId: productGroupId,
});
}
}),
);
return await prisma.product.createManyAndReturn({
data: dataProduct,
include: {
createdBy: true,
updatedBy: true,
},
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
},
);
if (productGroup.status === "CREATED") {
await prisma.productGroup.update({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: productGroupId },
data: { status: Status.ACTIVE },
});
}
this.setStatus(HttpStatus.CREATED);
return record;
}
} }
@Route("api/v1/product/{productId}") @Route("api/v1/product/{productId}")
@ -495,3 +689,43 @@ export class ProductFileController extends Controller {
return await deleteFile(fileLocation.product.img(productId, name)); return await deleteFile(fileLocation.product.img(productId, name));
} }
} }
@Route("api/v1/product-export")
@Tags("Product")
export class ProductExportController extends ProductController {
@Get()
@Security("keycloak")
async exportCustomer(
@Request() req: RequestWithUser,
@Query() status?: Status,
@Query() shared?: boolean,
@Query() productGroupId?: string,
@Query() query: string = "",
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() orderField?: keyof Product,
@Query() orderBy?: "asc" | "desc",
@Query() activeOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) {
const ret = await this.getProduct(
req,
status,
shared,
productGroupId,
query,
page,
pageSize,
orderField,
orderBy,
activeOnly,
startDate,
endDate,
);
this.setHeader("Content-Type", "text/csv");
return json2csv(ret.result, { useDateIso8601Format: true, expandNestedObjects: true });
}
}

View file

@ -27,7 +27,7 @@ import {
} from "../services/permission"; } from "../services/permission";
import { filterStatus } from "../services/prisma"; import { filterStatus } from "../services/prisma";
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
type ProductGroupCreate = { type ProductGroupCreate = {
name: string; name: string;
@ -35,7 +35,7 @@ type ProductGroupCreate = {
remark: string; remark: string;
status?: Status; status?: Status;
shared?: boolean; shared?: boolean;
registeredBranchId: string; registeredBranchId?: string;
}; };
type ProductGroupUpdate = { type ProductGroupUpdate = {
@ -51,14 +51,16 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"head_of_sale", "branch_admin",
"branch_manager",
"branch_accountant",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const permissionCond = createPermCondition((_) => true); const permissionCond = createPermCondition((_) => true);
@ -90,11 +92,13 @@ export class ProductGroup extends Controller {
@Query() page: number = 1, @Query() page: number = 1,
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() activeOnly?: boolean, @Query() activeOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.ProductGroupWhereInput[]>(query, [ OR: queryOrNot<Prisma.ProductGroupWhereInput[]>(query, [
{ name: { contains: query } }, { name: { contains: query, mode: "insensitive" } },
{ detail: { contains: query } }, { detail: { contains: query, mode: "insensitive" } },
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
]), ]),
AND: [ AND: [
@ -105,6 +109,7 @@ export class ProductGroup extends Controller {
: { OR: permissionCond(req.user, { activeOnly }) }, : { OR: permissionCond(req.user, { activeOnly }) },
}, },
], ],
...whereDateQuery(startDate, endDate),
} satisfies Prisma.ProductGroupWhereInput; } satisfies Prisma.ProductGroupWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -154,7 +159,23 @@ export class ProductGroup extends Controller {
@Post() @Post()
@Security("keycloak", MANAGE_ROLES) @Security("keycloak", MANAGE_ROLES)
async createProductGroup(@Request() req: RequestWithUser, @Body() body: ProductGroupCreate) { async createProductGroup(@Request() req: RequestWithUser, @Body() body: ProductGroupCreate) {
let company = await permissionCheck(req.user, body.registeredBranchId).then( const userAffiliatedBranch = await prisma.branch.findFirst({
include: branchRelationPermInclude(req.user),
where: body.registeredBranchId
? { id: body.registeredBranchId }
: {
user: { some: { userId: req.user.sub } },
},
});
if (!userAffiliatedBranch) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"You must be affilated with at least one branch or specify branch to be registered (System permission required).",
"reqMinAffilatedBranch",
);
}
let company = await permissionCheck(req.user, userAffiliatedBranch).then(
(v) => (v.headOffice || v).code, (v) => (v.headOffice || v).code,
); );
@ -178,6 +199,7 @@ export class ProductGroup extends Controller {
}, },
data: { data: {
...body, ...body,
registeredBranchId: userAffiliatedBranch.id,
statusOrder: +(body.status === "INACTIVE"), statusOrder: +(body.status === "INACTIVE"),
code: `G${last.value.toString().padStart(2, "0")}`, code: `G${last.value.toString().padStart(2, "0")}`,
createdByUserId: req.user.sub, createdByUserId: req.user.sub,

View file

@ -24,7 +24,7 @@ import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status"; import HttpStatus from "../interfaces/http-status";
import { notFoundError } from "../utils/error"; import { notFoundError } from "../utils/error";
import { filterStatus } from "../services/prisma"; import { filterStatus } from "../services/prisma";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
type PropertyPayload = { type PropertyPayload = {
name: string; name: string;
@ -49,15 +49,21 @@ export class PropertiesController extends Controller {
@Query() status?: Status, @Query() status?: Status,
@Query() query = "", @Query() query = "",
@Query() activeOnly?: boolean, @Query() activeOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: queryOrNot(query, [{ name: { contains: query } }, { nameEN: { contains: query } }]), OR: queryOrNot(query, [
{ name: { contains: query, mode: "insensitive" } },
{ nameEN: { contains: query, mode: "insensitive" } },
]),
AND: { AND: {
...filterStatus(activeOnly ? Status.ACTIVE : status), ...filterStatus(activeOnly ? Status.ACTIVE : status),
registeredBranch: { registeredBranch: {
OR: permissionCondCompany(req.user, { activeOnly: true }), OR: permissionCondCompany(req.user, { activeOnly: true }),
}, },
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.PropertyWhereInput; } satisfies Prisma.PropertyWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
prisma.property.findMany({ prisma.property.findMany({

View file

@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import { notFoundError } from "../utils/error"; import { notFoundError } from "../utils/error";
import { RequestWithUser } from "../interfaces/user"; import { RequestWithUser } from "../interfaces/user";
import { createPermCondition } from "../services/permission"; import { createPermCondition } from "../services/permission";
import { whereDateQuery } from "../utils/relation";
const permissionCondCompany = createPermCondition((_) => true); const permissionCondCompany = createPermCondition((_) => true);
@ -21,6 +22,8 @@ export class ReceiptController extends Controller {
@Query() quotationId?: string, @Query() quotationId?: string,
@Query() debitNoteId?: string, @Query() debitNoteId?: string,
@Query() debitNoteOnly?: boolean, @Query() debitNoteOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where: Prisma.PaymentWhereInput = { const where: Prisma.PaymentWhereInput = {
paymentStatus: "PaymentSuccess", paymentStatus: "PaymentSuccess",
@ -33,6 +36,7 @@ export class ReceiptController extends Controller {
}, },
}, },
}, },
...whereDateQuery(startDate, endDate),
}; };
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([

View file

@ -36,20 +36,22 @@ import {
listFile, listFile,
setFile, setFile,
} from "../utils/minio"; } from "../utils/minio";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
const MANAGE_ROLES = [ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"head_of_sale", "branch_admin",
"branch_manager",
"branch_accountant",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = MANAGE_ROLES;
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const permissionCondCompany = createPermCondition((_) => true); const permissionCondCompany = createPermCondition((_) => true);
@ -164,6 +166,8 @@ export class ServiceController extends Controller {
@Query() fullDetail?: boolean, @Query() fullDetail?: boolean,
@Query() activeOnly?: boolean, @Query() activeOnly?: boolean,
@Query() shared?: boolean, @Query() shared?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
// NOTE: will be used to scope product within product group that is shared between branch but not company when select shared product if user is system // NOTE: will be used to scope product within product group that is shared between branch but not company when select shared product if user is system
const targetGroup = const targetGroup =
@ -179,8 +183,8 @@ export class ServiceController extends Controller {
const where = { const where = {
OR: queryOrNot<Prisma.ServiceWhereInput[]>(query, [ OR: queryOrNot<Prisma.ServiceWhereInput[]>(query, [
{ name: { contains: query } }, { name: { contains: query, mode: "insensitive" } },
{ detail: { contains: query } }, { detail: { contains: query, mode: "insensitive" } },
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
]), ]),
AND: { AND: {
@ -219,6 +223,7 @@ export class ServiceController extends Controller {
: []), : []),
], ],
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.ServiceWhereInput; } satisfies Prisma.ServiceWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([

View file

@ -18,6 +18,7 @@ import prisma from "../db";
import { RequestWithUser } from "../interfaces/user"; import { RequestWithUser } from "../interfaces/user";
import HttpStatus from "../interfaces/http-status"; import HttpStatus from "../interfaces/http-status";
import { isUsedError, notFoundError } from "../utils/error"; import { isUsedError, notFoundError } from "../utils/error";
import { whereDateQuery } from "../utils/relation";
type WorkCreate = { type WorkCreate = {
order: number; order: number;
@ -45,9 +46,12 @@ export class WorkController extends Controller {
@Query() query: string = "", @Query() query: string = "",
@Query() page: number = 1, @Query() page: number = 1,
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: [{ name: { contains: query }, serviceId: baseOnly ? null : undefined }], OR: [{ name: { contains: query }, serviceId: baseOnly ? null : undefined }],
...whereDateQuery(startDate, endDate),
} satisfies Prisma.WorkWhereInput; } satisfies Prisma.WorkWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([

View file

@ -26,11 +26,20 @@ import flowAccount from "../services/flowaccount";
import HttpError from "../interfaces/http-error"; import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status"; import HttpStatus from "../interfaces/http-status";
const MANAGE_ROLES = ["system", "head_of_admin", "admin", "head_of_accountant", "accountant"]; const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"executive",
"accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant"]; const listAllowed = ["system", "head_of_admin", "admin", "executive", "accountant"];
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const permissionCondCompany = createPermCondition((_) => true); const permissionCondCompany = createPermCondition((_) => true);
@ -101,10 +110,19 @@ export class QuotationPayment extends Controller {
} }
@Put("{paymentId}") @Put("{paymentId}")
@Security("keycloak", MANAGE_ROLES) @Security("keycloak", MANAGE_ROLES.concat(["head_of_sale", "sale"]))
async updatePayment( async updatePayment(
@Path() paymentId: string, @Path() paymentId: string,
@Body() body: { amount?: number; date?: Date; paymentStatus?: PaymentStatus }, @Body()
body: {
amount?: number;
date?: Date;
paymentStatus?: PaymentStatus;
channel?: string | null;
account?: string | null;
reference?: string | null;
},
@Request() req: RequestWithUser,
) { ) {
const record = await prisma.payment.findUnique({ const record = await prisma.payment.findUnique({
where: { id: paymentId }, where: { id: paymentId },
@ -134,7 +152,18 @@ export class QuotationPayment extends Controller {
if (!record) throw notFoundError("Payment"); if (!record) throw notFoundError("Payment");
if (record.paymentStatus === "PaymentSuccess") return record; if (record.paymentStatus === "PaymentSuccess") {
const { channel, account, reference } = body;
return await prisma.payment.update({
where: { id: paymentId, invoice: { quotationId: record.invoice.quotationId } },
data: {
channel,
account,
reference,
updatedByUserId: req.user.sub,
},
});
}
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const current = new Date(); const current = new Date();
@ -164,6 +193,7 @@ export class QuotationPayment extends Controller {
code: lastReceipt code: lastReceipt
? `RE${year}${month}${lastReceipt.value.toString().padStart(6, "0")}` ? `RE${year}${month}${lastReceipt.value.toString().padStart(6, "0")}`
: undefined, : undefined,
updatedByUserId: req.user.sub,
}, },
}); });
@ -179,6 +209,7 @@ export class QuotationPayment extends Controller {
await tx.quotation await tx.quotation
.update({ .update({
include: { requestData: true },
where: { id: quotation.id }, where: { id: quotation.id },
data: { data: {
quotationStatus: quotationStatus:
@ -236,6 +267,17 @@ export class QuotationPayment extends Controller {
receiverId: res.createdByUserId, receiverId: res.createdByUserId,
}, },
}); });
if (quotation.quotationStatus === "PaymentInProcess") {
await prisma.notification.create({
data: {
title: "รายการคำขอใหม่ / New Request",
detail: "รหัส / code : " + res.requestData.map((v) => v.code).join(", "),
registeredBranchId: res.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
});
}
}); });
return payment; return payment;

View file

@ -25,7 +25,7 @@ import {
import { isSystem } from "../utils/keycloak"; import { isSystem } from "../utils/keycloak";
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
import { precisionRound } from "../utils/arithmetic"; import { precisionRound } from "../utils/arithmetic";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio"; import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio";
import HttpError from "../interfaces/http-error"; import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status"; import HttpStatus from "../interfaces/http-status";
@ -55,13 +55,14 @@ type QuotationCreate = {
dateOfBirth: Date; dateOfBirth: Date;
gender: string; gender: string;
nationality: string; nationality: string;
otherNationality?: string | null;
namePrefix?: string; namePrefix?: string;
firstName: string; firstName: string;
firstNameEN: string; firstNameEN: string;
middleName?: string; middleName?: string;
middleNameEN?: string; middleNameEN?: string;
lastName: string; lastName: string;
lastNameEN: string; lastNameEN?: string;
} }
)[]; )[];
@ -83,6 +84,8 @@ type QuotationCreate = {
installmentNo?: number; installmentNo?: number;
workerIndex?: number[]; workerIndex?: number[];
}[]; }[];
sellerId?: string;
}; };
type QuotationUpdate = { type QuotationUpdate = {
@ -112,14 +115,15 @@ type QuotationUpdate = {
dateOfBirth: Date; dateOfBirth: Date;
gender: string; gender: string;
nationality: string; nationality: string;
otherNationality?: string | null;
namePrefix?: string; namePrefix?: string;
firstName: string; firstName?: string;
firstNameEN: string; firstNameEN: string;
middleName?: string; middleName?: string;
middleNameEN?: string; middleNameEN?: string;
lastName: string; lastName?: string;
lastNameEN: string; lastNameEN?: string;
} }
)[]; )[];
@ -140,6 +144,8 @@ type QuotationUpdate = {
installmentNo?: number; installmentNo?: number;
workerIndex?: number[]; workerIndex?: number[];
}[]; }[];
sellerId?: string;
}; };
const VAT_DEFAULT = config.vat; const VAT_DEFAULT = config.vat;
@ -148,15 +154,16 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"head_of_sale", "branch_admin",
"sale", "branch_manager",
"branch_accountant",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = ["system", "head_of_admin", "admin", "executive", "accountant"];
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const permissionCheckCompany = createPermCheck((_) => true); const permissionCheckCompany = createPermCheck((_) => true);
@ -206,20 +213,22 @@ export class QuotationController extends Controller {
@Query() forDebitNote?: boolean, @Query() forDebitNote?: boolean,
@Query() code?: string, @Query() code?: string,
@Query() query = "", @Query() query = "",
@Query() startDate?: Date,
@Query() endDate?: Date,
@Query() sellerId?: string,
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.QuotationWhereInput[]>(query, [ OR: queryOrNot<Prisma.QuotationWhereInput[]>(query, [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ workName: { contains: query } }, { workName: { contains: query, mode: "insensitive" } },
{ {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } },
], ],
}, },
}, },
@ -253,6 +262,8 @@ export class QuotationController extends Controller {
}, },
} }
: undefined, : undefined,
...whereDateQuery(startDate, endDate),
sellerId: sellerId,
} satisfies Prisma.QuotationWhereInput; } satisfies Prisma.QuotationWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -410,7 +421,7 @@ export class QuotationController extends Controller {
} }
@Post() @Post()
@Security("keycloak", MANAGE_ROLES) @Security("keycloak", MANAGE_ROLES.concat(["head_of_sale", "sale"]))
async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) { async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) {
const ids = { const ids = {
employee: body.worker.filter((v) => typeof v === "string"), employee: body.worker.filter((v) => typeof v === "string"),
@ -516,16 +527,15 @@ export class QuotationController extends Controller {
const vatIncluded = body.agentPrice ? p.agentPriceVatIncluded : p.vatIncluded; const vatIncluded = body.agentPrice ? p.agentPriceVatIncluded : p.vatIncluded;
const originalPrice = body.agentPrice ? p.agentPrice : p.price; const originalPrice = body.agentPrice ? p.agentPrice : p.price;
const finalPriceWithVat = precisionRound( const finalPrice = precisionRound(
originalPrice + (vatIncluded ? 0 : originalPrice * VAT_DEFAULT), originalPrice + (vatIncluded ? 0 : originalPrice * VAT_DEFAULT),
); );
const pricePerUnit = finalPrice / (1 + VAT_DEFAULT);
const price = finalPriceWithVat;
const pricePerUnit = price / (1 + VAT_DEFAULT);
const vat = (body.agentPrice ? p.agentPriceCalcVat : p.calcVat) const vat = (body.agentPrice ? p.agentPriceCalcVat : p.calcVat)
? (pricePerUnit * v.amount - (v.discount || 0)) * VAT_DEFAULT ? ((pricePerUnit * (1 + VAT_DEFAULT) * v.amount - (v.discount || 0)) /
(1 + VAT_DEFAULT)) *
VAT_DEFAULT
: 0; : 0;
return { return {
order: i + 1, order: i + 1,
productId: v.productId, productId: v.productId,
@ -546,13 +556,13 @@ export class QuotationController extends Controller {
const price = list.reduce( const price = list.reduce(
(a, c) => { (a, c) => {
a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount); const vat = c.vat ? VAT_DEFAULT : 0;
const price = c.pricePerUnit * c.amount * (1 + vat) - c.discount;
a.totalPrice = precisionRound(a.totalPrice + price / (1 + vat) + c.discount);
a.totalDiscount = precisionRound(a.totalDiscount + c.discount); a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
a.vat = precisionRound(a.vat + c.vat); a.vat = precisionRound(a.vat + c.vat);
a.vatExcluded = a.vatExcluded = c.vat === 0 ? precisionRound(a.vatExcluded + price) : a.vatExcluded;
c.vat === 0
? precisionRound(a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)))
: a.vatExcluded;
a.finalPrice = precisionRound( a.finalPrice = precisionRound(
Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0), Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0),
); );
@ -653,7 +663,14 @@ export class QuotationController extends Controller {
title: "ใบเสนอราคาใหม่ / New Quotation", title: "ใบเสนอราคาใหม่ / New Quotation",
detail: "รหัส / code : " + ret.code, detail: "รหัส / code : " + ret.code,
registeredBranchId: ret.registeredBranchId, registeredBranchId: ret.registeredBranchId,
groupReceiver: { create: [{ name: "sale" }, { name: "head_of_sale" }] }, groupReceiver: {
create: [
{ name: "sale" },
{ name: "head_of_sale" },
{ name: "accountant" },
{ name: "branch_accountant" },
],
},
}, },
}); });
@ -661,7 +678,7 @@ export class QuotationController extends Controller {
} }
@Put("{quotationId}") @Put("{quotationId}")
@Security("keycloak", MANAGE_ROLES) @Security("keycloak", MANAGE_ROLES.concat(["head_of_sale", "sale"]))
async editQuotation( async editQuotation(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Path() quotationId: string, @Path() quotationId: string,
@ -797,14 +814,14 @@ export class QuotationController extends Controller {
const vatIncluded = record.agentPrice ? p.agentPriceVatIncluded : p.vatIncluded; const vatIncluded = record.agentPrice ? p.agentPriceVatIncluded : p.vatIncluded;
const originalPrice = record.agentPrice ? p.agentPrice : p.price; const originalPrice = record.agentPrice ? p.agentPrice : p.price;
const finalPriceWithVat = precisionRound( const finalPrice = precisionRound(
originalPrice + (vatIncluded ? 0 : originalPrice * VAT_DEFAULT), originalPrice + (vatIncluded ? 0 : originalPrice * VAT_DEFAULT),
); );
const pricePerUnit = finalPrice / (1 + VAT_DEFAULT);
const price = finalPriceWithVat;
const pricePerUnit = price / (1 + VAT_DEFAULT);
const vat = (record.agentPrice ? p.agentPriceCalcVat : p.calcVat) const vat = (record.agentPrice ? p.agentPriceCalcVat : p.calcVat)
? (pricePerUnit * v.amount - (v.discount || 0)) * VAT_DEFAULT ? ((pricePerUnit * (1 + VAT_DEFAULT) * v.amount - (v.discount || 0)) /
(1 + VAT_DEFAULT)) *
VAT_DEFAULT
: 0; : 0;
return { return {
@ -827,15 +844,13 @@ export class QuotationController extends Controller {
const price = list?.reduce( const price = list?.reduce(
(a, c) => { (a, c) => {
a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount); const vat = c.vat ? VAT_DEFAULT : 0;
const price = c.pricePerUnit * c.amount * (1 + vat) - c.discount;
a.totalPrice = precisionRound(a.totalPrice + price / (1 + vat) + c.discount);
a.totalDiscount = precisionRound(a.totalDiscount + c.discount); a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
a.vat = precisionRound(a.vat + c.vat); a.vat = precisionRound(a.vat + c.vat);
a.vatExcluded = a.vatExcluded = c.vat === 0 ? precisionRound(a.vatExcluded + price) : a.vatExcluded;
c.vat === 0
? precisionRound(
a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT,
)
: a.vatExcluded;
a.finalPrice = precisionRound( a.finalPrice = precisionRound(
Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0), Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0),
); );
@ -851,6 +866,7 @@ export class QuotationController extends Controller {
finalPrice: 0, finalPrice: 0,
}, },
); );
const changed = list?.some((lhs) => { const changed = list?.some((lhs) => {
const found = record.productServiceList.find((rhs) => { const found = record.productServiceList.find((rhs) => {
return ( return (
@ -884,6 +900,20 @@ export class QuotationController extends Controller {
}), }),
]); ]);
if (customerBranch) {
await tx.customerBranch.update({
where: { id: customerBranch.id },
data: {
customer: {
update: {
status: Status.ACTIVE,
},
},
status: Status.ACTIVE,
},
});
}
return await tx.quotation.update({ return await tx.quotation.update({
include: { include: {
productServiceList: { productServiceList: {
@ -1005,6 +1035,7 @@ export class QuotationActionController extends Controller {
dateOfBirth: Date; dateOfBirth: Date;
gender: string; gender: string;
nationality: string; nationality: string;
otherNationality?: string | null;
namePrefix?: string; namePrefix?: string;
firstName: string; firstName: string;
firstNameEN: string; firstNameEN: string;
@ -1027,6 +1058,7 @@ export class QuotationActionController extends Controller {
dateOfBirth: Date; dateOfBirth: Date;
gender: string; gender: string;
nationality: string; nationality: string;
otherNationality?: string | null;
namePrefix?: string; namePrefix?: string;
firstName: string; firstName: string;
firstNameEN: string; firstNameEN: string;

View file

@ -27,11 +27,12 @@ import {
createPermCheck, createPermCheck,
createPermCondition, createPermCondition,
} from "../services/permission"; } from "../services/permission";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
import { notFoundError } from "../utils/error"; import { notFoundError } from "../utils/error";
import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio"; import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio";
import HttpError from "../interfaces/http-error"; import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status"; import HttpStatus from "../interfaces/http-status";
import { getGroupUser } from "../services/keycloak";
// User in company can edit. // User in company can edit.
const permissionCheck = createPermCheck((_) => true); const permissionCheck = createPermCheck((_) => true);
@ -80,45 +81,53 @@ export class RequestDataController extends Controller {
@Query() requestDataStatus?: RequestDataStatus, @Query() requestDataStatus?: RequestDataStatus,
@Query() quotationId?: string, @Query() quotationId?: string,
@Query() code?: string, @Query() code?: string,
@Query() incomplete?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.RequestDataWhereInput[]>(query, [ OR: queryOrNot<Prisma.RequestDataWhereInput[]>(query, [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ quotation: { code: { contains: query, mode: "insensitive" } } }, { quotation: { code: { contains: query, mode: "insensitive" } } },
{ quotation: { workName: { contains: query } } }, { quotation: { workName: { contains: query, mode: "insensitive" } } },
{ {
quotation: { quotation: {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query } }, { registerName: { contains: query, mode: "insensitive" } },
{ registerName: { contains: query } }, { registerNameEN: { contains: query, mode: "insensitive" } },
{ registerNameEN: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } },
], ],
}, },
}, },
},
{
employee: { employee: {
OR: [ OR: [
{ {
employeePassport: { employeePassport: {
some: { number: { contains: query } }, some: { number: { contains: query, mode: "insensitive" } },
}, },
}, },
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
], ],
}, },
}, },
]), ]),
code, code,
requestDataStatus, requestDataStatus: incomplete
? {
notIn: [RequestDataStatus.Completed, RequestDataStatus.Canceled],
}
: requestDataStatus,
requestWork: responsibleOnly requestWork: responsibleOnly
? { ? {
some: { some: {
@ -127,9 +136,24 @@ export class RequestDataController extends Controller {
workflow: { workflow: {
step: { step: {
some: { some: {
responsiblePerson: { OR: [
some: { userId: req.user.sub }, {
}, responsiblePerson: {
some: { userId: req.user.sub },
},
},
{
responsibleGroup: {
some: {
group: {
in: await getGroupUser(req.user.sub).then((r) =>
r.map(({ name }: { name: string }) => name),
),
},
},
},
},
],
}, },
}, },
}, },
@ -142,6 +166,7 @@ export class RequestDataController extends Controller {
id: quotationId, id: quotationId,
registeredBranch: { OR: permissionCond(req.user) }, registeredBranch: { OR: permissionCond(req.user) },
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.RequestDataWhereInput; } satisfies Prisma.RequestDataWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -164,6 +189,7 @@ export class RequestDataController extends Controller {
include: { user: true }, include: { user: true },
}, },
responsibleInstitution: true, responsibleInstitution: true,
responsibleGroup: true,
}, },
}, },
}, },
@ -182,6 +208,20 @@ export class RequestDataController extends Controller {
employeePassport: { employeePassport: {
orderBy: { expireDate: "desc" }, orderBy: { expireDate: "desc" },
}, },
customerBranch: {
include: {
province: {
include: {
employmentOffice: true,
},
},
district: {
include: {
employmentOffice: true,
},
},
},
},
}, },
}, },
}, },
@ -192,7 +232,24 @@ export class RequestDataController extends Controller {
prisma.requestData.count({ where }), prisma.requestData.count({ where }),
]); ]);
return { result, page, pageSize, total }; const dataRequestData = result.map((item) => {
const employee = item.employee;
const dataOffice =
employee.customerBranch.district?.employmentOffice.at(0) ??
employee.customerBranch.province?.employmentOffice.at(0);
return {
...item,
dataOffice,
};
});
return {
result: dataRequestData,
page,
pageSize,
total,
};
} }
@Get("{requestDataId}") @Get("{requestDataId}")
@ -230,11 +287,68 @@ export class RequestDataController extends Controller {
return record; return record;
} }
@Post("update-messenger")
@Security("keycloak")
async updateRequestData(
@Request() req: RequestWithUser,
@Body()
body: {
defaultMessengerId: string;
requestDataId: string[];
},
) {
if (body.requestDataId.length === 0) return;
return await prisma.$transaction(async (tx) => {
const record = await tx.requestData.updateManyAndReturn({
where: {
id: { in: body.requestDataId },
quotation: {
registeredBranch: {
OR: permissionCond(req.user),
},
},
},
data: {
defaultMessengerId: body.defaultMessengerId,
},
});
if (record.length <= 0) throw notFoundError("Request Data");
await tx.requestWorkStepStatus.updateMany({
where: {
workStatus: {
in: [
RequestWorkStatus.Pending,
RequestWorkStatus.Waiting,
RequestWorkStatus.InProgress,
],
},
requestWork: {
requestDataId: { in: body.requestDataId },
},
},
data: { responsibleUserId: body.defaultMessengerId },
});
return record[0];
});
}
} }
@Route("/api/v1/request-data/{requestDataId}") @Route("/api/v1/request-data/{requestDataId}")
@Tags("Request List") @Tags("Request List")
export class RequestDataActionController extends Controller { export class RequestDataActionController extends Controller {
async #getLineToken() {
if (!process.env.LINE_MESSAGING_API_TOKEN) {
console.warn("Line Webhook Activated but LINE_MESSAGING_API_TOKEN not set.");
}
return process.env.LINE_MESSAGING_API_TOKEN;
}
@Post("reject-request-cancel") @Post("reject-request-cancel")
@Security("keycloak") @Security("keycloak")
async rejectRequestCancel( async rejectRequestCancel(
@ -309,6 +423,17 @@ export class RequestDataActionController extends Controller {
}, },
}, },
}, },
include: {
quotation: {
include: {
customerBranch: {
include: {
customer: { include: { branch: { where: { userId: { not: null } } } } },
},
},
},
},
},
}); });
if (!result) throw notFoundError("Request Data"); if (!result) throw notFoundError("Request Data");
@ -351,23 +476,88 @@ export class RequestDataActionController extends Controller {
data: { quotationStatus: QuotationStatus.Canceled, urgent: false }, data: { quotationStatus: QuotationStatus.Canceled, urgent: false },
}) })
.then(async (res) => { .then(async (res) => {
await tx.notification.createMany({ await Promise.all(
data: res.map((v) => ({ res.map((v) =>
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", tx.notification.create({
detail: "รหัส / code : " + v.code + " Canceled", data: {
receiverId: v.createdByUserId, title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
})), detail: "รหัส / code : " + v.code + " Canceled",
}); receiverId: v.createdByUserId,
registeredBranchId: v.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
}), }),
tx.taskOrder.updateMany({ tx.taskOrder
where: { .updateManyAndReturn({
taskList: { where: {
every: { taskStatus: TaskStatus.Canceled }, taskList: {
every: { taskStatus: TaskStatus.Canceled },
},
}, },
}, data: { taskOrderStatus: TaskStatus.Canceled },
data: { taskOrderStatus: TaskStatus.Canceled }, })
}), .then(async (res) => {
await Promise.all(
res.map((v) =>
tx.notification.create({
data: {
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
detail: "รหัส / code : " + v.code + " Canceled",
receiverId: v.createdByUserId,
registeredBranchId: v.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
}),
]); ]);
const token = await this.#getLineToken();
if (!token) return;
const textHead = "JWS ALERT:";
const textAlert = "ขอแจ้งให้ทราบว่าใบเสนอราคา";
const textAlert2 = "ได้ดำเนินการยกเลิกเรียบร้อยแล้ว";
const textAlert3 = "หากต้องการข้อมูลเพิ่มเติม กรุณาแจ้งให้ฝ่ายที่เกี่ยวข้องทราบ 🙏";
let finalTextWork = "";
let textData = "";
let dataCustomerId: string[] = [];
let dataUserId: string[] = [];
result.quotation.customerBranch.customer.branch.forEach((item) => {
if (!dataCustomerId?.includes(item.id) && item.userId) {
dataCustomerId.push(item.id);
dataUserId.push(item.userId);
}
});
finalTextWork = `เลขที่ใบเสนอราคา: ${result.code} ${result.quotation.workName}`;
textData = `${textHead}\n\n${textAlert}\n${finalTextWork}\n${textAlert2}\n\n${textAlert3}`;
const data = {
to: dataUserId,
messages: [
{
type: "text",
text: textData,
},
],
};
await fetch("https://api.line.me/v2/bot/message/multicast", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
}); });
} }
@ -499,13 +689,19 @@ export class RequestDataActionController extends Controller {
data: { quotationStatus: QuotationStatus.Canceled, urgent: false }, data: { quotationStatus: QuotationStatus.Canceled, urgent: false },
}) })
.then(async (res) => { .then(async (res) => {
await tx.notification.createMany({ await Promise.all(
data: res.map((v) => ({ res.map((v) =>
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", tx.notification.create({
detail: "รหัส / code : " + v.code + " Canceled", data: {
receiverId: v.createdByUserId, title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
})), detail: "รหัส / code : " + v.code + " Canceled",
}); receiverId: v.createdByUserId,
registeredBranchId: v.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
}), }),
tx.taskOrder.updateMany({ tx.taskOrder.updateMany({
where: { where: {
@ -588,14 +784,83 @@ export class RequestDataActionController extends Controller {
}, },
}, },
data: { quotationStatus: QuotationStatus.ProcessComplete, urgent: false }, data: { quotationStatus: QuotationStatus.ProcessComplete, urgent: false },
include: {
customerBranch: {
include: {
customer: {
include: {
branch: {
where: { userId: { not: null } },
},
},
},
},
},
},
}) })
.then(async (res) => { .then(async (res) => {
await tx.notification.createMany({ await Promise.all(
data: res.map((v) => ({ res.map((v) =>
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", tx.notification.create({
detail: "รหัส / code : " + v.code + " Completed", data: {
receiverId: v.createdByUserId, title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
})), detail: "รหัส / code : " + v.code + " Completed",
receiverId: v.createdByUserId,
registeredBranchId: v.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
const token = await this.#getLineToken();
if (!token) return;
const textHead = "JWS ALERT:";
const textAlert = "ขอแจ้งให้ทราบว่าใบเสนอราคา";
const textAlert2 = "ได้ดำเนินการเสร็จสิ้นทุกกระบวนการเรียบร้อยแล้ว";
const textAlert3 = "หากต้องการข้อมูลเพิ่มเติม กรุณาแจ้งให้ฝ่ายที่เกี่ยวข้องทราบ 🙏";
let finalTextWork = "";
let textData = "";
let dataCustomerId: string[] = [];
let textWorkList: string[] = [];
let dataUserId: string[] = [];
if (res) {
res.forEach((data, index) => {
data.customerBranch.customer.branch.forEach((item) => {
if (!dataCustomerId?.includes(item.id) && item.userId) {
dataCustomerId.push(item.id);
dataUserId.push(item.userId);
}
});
textWorkList.push(`${index + 1}. เลขที่ใบเสนอราคา ${data.code} ${data.workName}`);
});
finalTextWork = textWorkList.join("\n");
}
textData = `${textHead}\n\n${textAlert}\n${finalTextWork}\n${textAlert2}\n\n${textAlert3}`;
const data = {
to: dataUserId,
messages: [
{
type: "text",
text: textData,
},
],
};
await fetch("https://api.line.me/v2/bot/message/multicast", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
}); });
}); });
// dataRecord.push(record); // dataRecord.push(record);
@ -719,6 +984,7 @@ export class RequestListController extends Controller {
include: { user: true }, include: { user: true },
}, },
responsibleInstitution: true, responsibleInstitution: true,
responsibleGroup: true,
}, },
}, },
}, },
@ -779,6 +1045,7 @@ export class RequestListController extends Controller {
include: { user: true }, include: { user: true },
}, },
responsibleInstitution: true, responsibleInstitution: true,
responsibleGroup: true,
}, },
}, },
}, },
@ -887,6 +1154,21 @@ export class RequestListController extends Controller {
update: payload, update: payload,
}); });
if (record.responsibleUserId === null) {
await tx.requestWorkStepStatus.update({
where: {
step_requestWorkId: {
step: step,
requestWorkId,
},
responsibleUserId: null,
},
data: {
responsibleUserId: record.requestWork?.request.defaultMessengerId,
},
});
}
switch (payload.workStatus) { switch (payload.workStatus) {
case "Ready": case "Ready":
if (record.requestWork.request.requestDataStatus === "Pending") { if (record.requestWork.request.requestDataStatus === "Pending") {
@ -935,13 +1217,19 @@ export class RequestListController extends Controller {
data: { quotationStatus: QuotationStatus.Canceled, urgent: false }, data: { quotationStatus: QuotationStatus.Canceled, urgent: false },
}) })
.then(async (res) => { .then(async (res) => {
await tx.notification.createMany({ await Promise.all(
data: res.map((v) => ({ res.map((v) =>
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", tx.notification.create({
detail: "รหัส / code : " + v.code + " Canceled", data: {
receiverId: v.createdByUserId, title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
})), detail: "รหัส / code : " + v.code + " Canceled",
}); receiverId: v.createdByUserId,
registeredBranchId: v.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
}), }),
tx.taskOrder.updateMany({ tx.taskOrder.updateMany({
where: { where: {
@ -1049,13 +1337,19 @@ export class RequestListController extends Controller {
}, },
}) })
.then(async (res) => { .then(async (res) => {
await tx.notification.createMany({ await Promise.all(
data: res.map((v) => ({ res.map((v) =>
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", tx.notification.create({
detail: "รหัส / code : " + v.code + " Completed", data: {
receiverId: v.createdByUserId, title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
})), detail: "รหัส / code : " + v.code + " Completed",
}); receiverId: v.createdByUserId,
registeredBranchId: v.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
const token = await this.#getLineToken(); const token = await this.#getLineToken();
if (!token) return; if (!token) return;

View file

@ -42,13 +42,23 @@ import {
listFile, listFile,
setFile, setFile,
} from "../utils/minio"; } from "../utils/minio";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
const MANAGE_ROLES = ["system", "head_of_admin", "admin", "document_checker"]; const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"executive",
"accountant",
"branch_admin",
"branch_manager",
"branch_accountant",
"data_entry",
];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin"]; const listAllowed = ["system", "head_of_admin", "admin", "executive", "accountant"];
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const permissionCondCompany = createPermCondition((_) => true); const permissionCondCompany = createPermCondition((_) => true);
@ -60,11 +70,14 @@ const permissionCheckCompany = createPermCheck((_) => true);
@Tags("Task Order") @Tags("Task Order")
export class TaskController extends Controller { export class TaskController extends Controller {
@Get("stats") @Get("stats")
async getTaskOrderStats() { @Security("keycloak")
async getTaskOrderStats(@Request() req: RequestWithUser) {
const task = await prisma.taskOrder.groupBy({ const task = await prisma.taskOrder.groupBy({
where: { registeredBranch: { OR: permissionCondCompany(req.user) } },
by: ["taskOrderStatus"], by: ["taskOrderStatus"],
_count: true, _count: true,
}); });
return task.reduce<Record<TaskOrderStatus, number>>( return task.reduce<Record<TaskOrderStatus, number>>(
(a, c) => Object.assign(a, { [c.taskOrderStatus]: c._count }), (a, c) => Object.assign(a, { [c.taskOrderStatus]: c._count }),
{ {
@ -86,6 +99,8 @@ export class TaskController extends Controller {
@Query() pageSize = 30, @Query() pageSize = 30,
@Query() assignedByUserId?: string, @Query() assignedByUserId?: string,
@Query() taskOrderStatus?: TaskOrderStatus, @Query() taskOrderStatus?: TaskOrderStatus,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
return this.getTaskOrderListByCriteria( return this.getTaskOrderListByCriteria(
req, req,
@ -94,6 +109,8 @@ export class TaskController extends Controller {
pageSize, pageSize,
assignedByUserId, assignedByUserId,
taskOrderStatus, taskOrderStatus,
startDate,
endDate,
); );
} }
@ -106,6 +123,8 @@ export class TaskController extends Controller {
@Query() pageSize = 30, @Query() pageSize = 30,
@Query() assignedUserId?: string, @Query() assignedUserId?: string,
@Query() taskOrderStatus?: TaskOrderStatus, @Query() taskOrderStatus?: TaskOrderStatus,
@Query() startDate?: Date,
@Query() endDate?: Date,
@Body() body?: { code?: string[] }, @Body() body?: { code?: string[] },
) { ) {
const where = { const where = {
@ -121,10 +140,11 @@ export class TaskController extends Controller {
code: body?.code ? { in: body.code } : undefined, code: body?.code ? { in: body.code } : undefined,
OR: queryOrNot(query, [ OR: queryOrNot(query, [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ taskName: { contains: query } }, { taskName: { contains: query, mode: "insensitive" } },
{ contactName: { contains: query } }, { contactName: { contains: query, mode: "insensitive" } },
{ contactTel: { contains: query } }, { contactTel: { contains: query, mode: "insensitive" } },
]), ]),
...whereDateQuery(startDate, endDate),
} satisfies Prisma.TaskOrderWhereInput; } satisfies Prisma.TaskOrderWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -193,6 +213,7 @@ export class TaskController extends Controller {
step: { step: {
include: { include: {
value: true, value: true,
responsibleGroup: true,
responsiblePerson: { responsiblePerson: {
include: { user: true }, include: { user: true },
}, },
@ -244,6 +265,12 @@ export class TaskController extends Controller {
taskProduct?: { productId: string; discount?: number }[]; taskProduct?: { productId: string; discount?: number }[];
}, },
) { ) {
if (body.taskList.length < 1 || !body.registeredBranchId)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Your created invalid task order",
"taskOrderInvalid",
);
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const last = await tx.runningNo.upsert({ const last = await tx.runningNo.upsert({
where: { where: {
@ -293,8 +320,8 @@ export class TaskController extends Controller {
if (updated.count !== taskList.length) { if (updated.count !== taskList.length) {
throw new HttpError( throw new HttpError(
HttpStatus.PRECONDITION_FAILED, HttpStatus.PRECONDITION_FAILED,
"All request work to issue task order must be in ready state.", "all request work to issue task order must be in ready state.",
"requestWorkMustReady", "requestworkmustready",
); );
} }
await tx.institution.updateMany({ await tx.institution.updateMany({
@ -317,49 +344,51 @@ export class TaskController extends Controller {
where: { OR: taskList }, where: { OR: taskList },
}); });
return await tx.taskOrder.create({ return await tx.taskOrder
include: { .create({
taskList: { include: {
include: { taskList: {
requestWorkStep: { include: {
include: { requestWorkStep: {
requestWork: { include: {
include: { requestWork: {
request: { include: {
include: { request: {
employee: true, include: {
quotation: { employee: true,
include: { quotation: {
customerBranch: { include: {
include: { customerBranch: {
customer: true, include: {
}, customer: true,
},
},
},
},
},
productService: {
include: {
service: {
include: {
workflow: {
include: {
step: {
include: {
value: true,
responsiblePerson: {
include: { user: true },
},
responsibleInstitution: true,
},
}, },
}, },
}, },
}, },
}, },
work: true, },
product: true, productService: {
include: {
service: {
include: {
workflow: {
include: {
step: {
include: {
value: true,
responsiblePerson: {
include: { user: true },
},
responsibleInstitution: true,
},
},
},
},
},
},
work: true,
product: true,
},
}, },
}, },
}, },
@ -367,20 +396,30 @@ export class TaskController extends Controller {
}, },
}, },
}, },
institution: true,
createdBy: true,
}, },
institution: true, data: {
createdBy: true, ...rest,
}, code,
data: { urgent: work.some((v) => v.requestWork.request.quotation.urgent),
...rest, registeredBranchId: userAffiliatedBranch.id,
code, createdByUserId: req.user.sub,
urgent: work.some((v) => v.requestWork.request.quotation.urgent), taskList: { create: taskList },
registeredBranchId: userAffiliatedBranch.id, taskProduct: { create: taskProduct },
createdByUserId: req.user.sub, },
taskList: { create: taskList }, })
taskProduct: { create: taskProduct }, .then(async (v) => {
}, await prisma.notification.create({
}); data: {
title: "ใบสั่งงานใหม่ / New Task Order",
detail: "รหัส / code : " + v.code,
registeredBranchId: v.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
});
return v;
});
}); });
} }
@ -523,6 +562,8 @@ export class TaskController extends Controller {
title: "มีการส่งงาน / Task Submitted", title: "มีการส่งงาน / Task Submitted",
detail: "รหัสใบสั่งงาน / Order : " + record.code, detail: "รหัสใบสั่งงาน / Order : " + record.code,
receiverId: record.createdByUserId, receiverId: record.createdByUserId,
registeredBranchId: record.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
}, },
}); });
} }
@ -598,7 +639,28 @@ export class TaskActionController extends Controller {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const promises = body.map(async (v) => { const promises = body.map(async (v) => {
const record = await tx.task.findFirst({ const record = await tx.task.findFirst({
include: { requestWorkStep: true }, include: {
requestWorkStep: {
include: {
requestWork: {
include: {
request: {
include: {
quotation: true,
employee: true,
},
},
productService: {
include: {
product: true,
},
},
},
},
},
},
taskOrder: true,
},
where: { where: {
step: v.step, step: v.step,
requestWorkId: v.requestWorkId, requestWorkId: v.requestWorkId,
@ -616,6 +678,25 @@ export class TaskActionController extends Controller {
data: { userTaskStatus: UserTaskStatus.Restart }, data: { userTaskStatus: UserTaskStatus.Restart },
}); });
} }
if (v.taskStatus === TaskStatus.Failed) {
const taskCode = record.taskOrder.code;
const taskName = record.taskOrder.taskName;
const productCode = record.requestWorkStep.requestWork.productService.product.code;
const productName = record.requestWorkStep.requestWork.productService.product.name;
const employeeName = `${record.requestWorkStep.requestWork.request.employee.namePrefix}.${record.requestWorkStep.requestWork.request.employee.firstNameEN} ${record.requestWorkStep.requestWork.request.employee.lastNameEN}`;
await tx.notification.create({
data: {
title: "ใบรายการคำขอที่จัดการเกิดปัญหา / Task Failed",
detail: `ใบรายการคำขอรหัส ${taskCode}: ${taskName} รหัสสินค้า ${productCode}: ${productName} ของลูกจ้าง ${employeeName} เกิดข้อผิดพลาด`,
groupReceiver: { create: { name: "document_checker" } },
receiverId: record.requestWorkStep.requestWork.request.quotation.createdByUserId,
registeredBranchId: record.taskOrder.registeredBranchId,
},
});
}
return await tx.task.update({ return await tx.task.update({
where: { id: record.id }, where: { id: record.id },
data: { data: {
@ -677,6 +758,8 @@ export class TaskActionController extends Controller {
title: "มีการส่งงาน / Task Submitted", title: "มีการส่งงาน / Task Submitted",
detail: "รหัสใบสั่งงาน / Order : " + record.code, detail: "รหัสใบสั่งงาน / Order : " + record.code,
receiverId: record.createdByUserId, receiverId: record.createdByUserId,
registeredBranchId: record.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
}, },
}), }),
]); ]);
@ -690,22 +773,53 @@ export class TaskActionController extends Controller {
if (!record) throw notFoundError("Task Order"); if (!record) throw notFoundError("Task Order");
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
const last = await tx.runningNo.upsert({
where: {
key: "TASK_RI",
},
create: {
key: "TASK_RI",
value: 1,
},
update: {
value: { increment: 1 },
},
});
const current = new Date();
const year = `${current.getFullYear()}`.padStart(2, "0");
const month = `${current.getMonth() + 1}`.padStart(2, "0");
const code = `RI${year}${month}${last.value.toString().padStart(6, "0")}`;
await Promise.all([ await Promise.all([
tx.taskOrder.update({ tx.taskOrder
where: { id: taskOrderId }, .update({
data: { where: { id: taskOrderId },
urgent: false, data: {
taskOrderStatus: TaskOrderStatus.Complete, urgent: false,
userTask: { taskOrderStatus: TaskOrderStatus.Complete,
updateMany: { codeProductReceived: code,
where: { taskOrderId }, userTask: {
data: { updateMany: {
userTaskStatus: UserTaskStatus.Submit, where: { taskOrderId },
data: {
userTaskStatus: UserTaskStatus.Submit,
},
}, },
}, },
}, },
}, })
}), .then(async (record) => {
await tx.notification.create({
data: {
title: "ใบงานเสร็จสิ้น / Task Complete",
detail: "รหัสใบสั่งงาน / Order : " + record.code,
receiverId: record.createdByUserId,
registeredBranchId: record.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
});
}),
tx.requestWorkStepStatus.updateMany({ tx.requestWorkStepStatus.updateMany({
where: { where: {
task: { task: {
@ -809,10 +923,34 @@ export class TaskActionController extends Controller {
if (completeCheck) completed.push(item.id); if (completeCheck) completed.push(item.id);
}); });
await tx.requestData.updateMany({ await tx.requestData
where: { id: { in: completed } }, .updateManyAndReturn({
data: { requestDataStatus: RequestDataStatus.Completed }, where: { id: { in: completed } },
}); include: {
quotation: {
select: {
registeredBranchId: true,
createdByUserId: true,
},
},
},
data: { requestDataStatus: RequestDataStatus.Completed },
})
.then(async (res) => {
await Promise.all(
res.map((v) =>
tx.notification.create({
data: {
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
detail: "รหัส / code : " + v.code + " Completed",
receiverId: v.quotation.createdByUserId,
registeredBranchId: v.quotation.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
});
await tx.quotation await tx.quotation
.updateManyAndReturn({ .updateManyAndReturn({
where: { where: {
@ -852,13 +990,19 @@ export class TaskActionController extends Controller {
}, },
}) })
.then(async (res) => { .then(async (res) => {
await tx.notification.createMany({ await Promise.all(
data: res.map((v) => ({ res.map((v) =>
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", tx.notification.create({
detail: "รหัส / code : " + v.code + " Completed", data: {
receiverId: v.createdByUserId, title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
})), detail: "รหัส / code : " + v.code + " Completed",
}); receiverId: v.createdByUserId,
registeredBranchId: v.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
const token = await this.#getLineToken(); const token = await this.#getLineToken();
@ -979,6 +1123,8 @@ export class UserTaskController extends Controller {
@Query() page = 1, @Query() page = 1,
@Query() pageSize = 30, @Query() pageSize = 30,
@Query() userTaskStatus?: UserTaskStatus, @Query() userTaskStatus?: UserTaskStatus,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
taskList: { taskList: {
@ -1021,10 +1167,11 @@ export class UserTaskController extends Controller {
: undefined, : undefined,
OR: queryOrNot(query, [ OR: queryOrNot(query, [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ taskName: { contains: query } }, { taskName: { contains: query, mode: "insensitive" } },
{ contactName: { contains: query } }, { contactName: { contains: query, mode: "insensitive" } },
{ contactTel: { contains: query } }, { contactTel: { contains: query, mode: "insensitive" } },
]), ]),
...whereDateQuery(startDate, endDate),
} satisfies Prisma.TaskOrderWhereInput; } satisfies Prisma.TaskOrderWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -1094,19 +1241,23 @@ export class UserTaskController extends Controller {
}, },
}) })
.then(async (v) => { .then(async (v) => {
await tx.notification.createMany({ await tx.notification.create({
data: [ data: {
{ title: "สถานะใบส่งงานมีการเปลี่ยนแปลง / Order Status Changed",
title: "สถานะใบส่งงานมีการเปลี่ยนแปลง / Order Status Changed", detail: "รหัสใบสั่งงาน / Order : " + v.code + " InProgress",
detail: "รหัสใบสั่งงาน / Order : " + v.code + " InProgress", receiverId: v.createdByUserId,
receiverId: v.createdByUserId, registeredBranchId: v.registeredBranchId,
}, groupReceiver: { create: { name: "document_checker" } },
{ },
title: "มีการรับงาน / Task Accepted", });
detail: "รหัสใบสั่งงาน / Order : " + v.code, await tx.notification.create({
receiverId: v.createdByUserId, data: {
}, title: "มีการรับงาน / Task Accepted",
], detail: "รหัสใบสั่งงาน / Order : " + v.code,
receiverId: v.createdByUserId,
registeredBranchId: v.registeredBranchId,
groupReceiver: { create: { name: "document_checker" } },
},
}); });
}), }),
tx.task.updateMany({ tx.task.updateMany({

View file

@ -13,6 +13,7 @@ import {
Security, Security,
Tags, Tags,
} from "tsoa"; } from "tsoa";
import config from "../config.json";
import prisma from "../db"; import prisma from "../db";
@ -35,29 +36,28 @@ import {
} from "../utils/minio"; } from "../utils/minio";
import { notFoundError } from "../utils/error"; import { notFoundError } from "../utils/error";
import { CreditNotePaybackType, CreditNoteStatus, Prisma, RequestDataStatus } from "@prisma/client"; import { CreditNotePaybackType, CreditNoteStatus, Prisma, RequestDataStatus } from "@prisma/client";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
import { PaybackStatus, RequestWorkStatus } from "../generated/kysely/types"; import { PaybackStatus, RequestWorkStatus } from "../generated/kysely/types";
const MANAGE_ROLES = [ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"head_of_sale", "branch_admin",
"sale", "branch_manager",
"branch_accountant",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = ["system", "head_of_admin", "admin", "executive", "accountant"];
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
const VAT_DEFAULT = config.vat;
// NOTE: permission condition/check in requestWork -> requestData -> quotation -> registeredBranch
const permissionCond = createPermCondition(globalAllow); const permissionCond = createPermCondition(globalAllow);
const permissionCondCompany = createPermCondition((_) => true);
const permissionCheck = createPermCheck(globalAllow); const permissionCheck = createPermCheck(globalAllow);
const permissionCheckCompany = createPermCheck((_) => true);
type CreditNoteCreate = { type CreditNoteCreate = {
requestWorkId: string[]; requestWorkId: string[];
@ -85,6 +85,14 @@ type CreditNoteUpdate = {
@Route("api/v1/credit-note") @Route("api/v1/credit-note")
@Tags("Credit Note") @Tags("Credit Note")
export class CreditNoteController extends Controller { export class CreditNoteController extends Controller {
async #getLineToken() {
if (!process.env.LINE_MESSAGING_API_TOKEN) {
console.warn("Line Webhook Activated but LINE_MESSAGING_API_TOKEN not set.");
}
return process.env.LINE_MESSAGING_API_TOKEN;
}
@Get("stats") @Get("stats")
@Security("keycloak") @Security("keycloak")
async getCreditNoteStats(@Request() req: RequestWithUser, @Query() quotationId?: string) { async getCreditNoteStats(@Request() req: RequestWithUser, @Query() quotationId?: string) {
@ -94,7 +102,7 @@ export class CreditNoteController extends Controller {
request: { request: {
quotationId, quotationId,
quotation: { quotation: {
registeredBranch: { OR: permissionCondCompany(req.user) }, registeredBranch: { OR: permissionCond(req.user) },
}, },
}, },
}, },
@ -121,6 +129,8 @@ export class CreditNoteController extends Controller {
@Query() query: string = "", @Query() query: string = "",
@Query() quotationId?: string, @Query() quotationId?: string,
@Query() creditNoteStatus?: CreditNoteStatus, @Query() creditNoteStatus?: CreditNoteStatus,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
return await this.getCreditNoteListByCriteria( return await this.getCreditNoteListByCriteria(
req, req,
@ -129,6 +139,8 @@ export class CreditNoteController extends Controller {
query, query,
quotationId, quotationId,
creditNoteStatus, creditNoteStatus,
startDate,
endDate,
); );
} }
@ -142,7 +154,8 @@ export class CreditNoteController extends Controller {
@Query() query: string = "", @Query() query: string = "",
@Query() quotationId?: string, @Query() quotationId?: string,
@Query() creditNoteStatus?: CreditNoteStatus, @Query() creditNoteStatus?: CreditNoteStatus,
@Body() body?: {}, @Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.CreditNoteWhereInput[]>(query, [ OR: queryOrNot<Prisma.CreditNoteWhereInput[]>(query, [
@ -153,17 +166,16 @@ export class CreditNoteController extends Controller {
request: { request: {
OR: queryOrNot<Prisma.RequestDataWhereInput[]>(query, [ OR: queryOrNot<Prisma.RequestDataWhereInput[]>(query, [
{ quotation: { code: { contains: query, mode: "insensitive" } } }, { quotation: { code: { contains: query, mode: "insensitive" } } },
{ quotation: { workName: { contains: query } } }, { quotation: { workName: { contains: query, mode: "insensitive" } } },
{ {
quotation: { quotation: {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } },
], ],
}, },
}, },
@ -171,14 +183,14 @@ export class CreditNoteController extends Controller {
OR: [ OR: [
{ {
employeePassport: { employeePassport: {
some: { number: { contains: query } }, some: { number: { contains: query, mode: "insensitive" } },
}, },
}, },
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
], ],
}, },
}, },
@ -194,16 +206,19 @@ export class CreditNoteController extends Controller {
request: { request: {
quotationId, quotationId,
quotation: { quotation: {
registeredBranch: { OR: permissionCondCompany(req.user) }, registeredBranch: { OR: permissionCond(req.user) },
}, },
}, },
}, },
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.CreditNoteWhereInput; } satisfies Prisma.CreditNoteWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
prisma.creditNote.findMany({ prisma.creditNote.findMany({
where, where,
take: pageSize,
skip: (page - 1) * pageSize,
include: { include: {
quotation: { quotation: {
include: { include: {
@ -236,7 +251,7 @@ export class CreditNoteController extends Controller {
some: { some: {
request: { request: {
quotation: { quotation: {
registeredBranch: { OR: permissionCondCompany(req.user) }, registeredBranch: { OR: permissionCond(req.user) },
}, },
}, },
}, },
@ -334,9 +349,8 @@ export class CreditNoteController extends Controller {
).length; ).length;
const price = const price =
c.productService.pricePerUnit - c.productService.pricePerUnit * (1 + (c.productService.vat > 0 ? VAT_DEFAULT : 0)) -
c.productService.discount / c.productService.amount + c.productService.discount;
c.productService.vat / c.productService.amount;
if (serviceChargeStepCount && successCount) { if (serviceChargeStepCount && successCount) {
return a + price - c.productService.product.serviceCharge * successCount; return a + price - c.productService.product.serviceCharge * successCount;
@ -362,40 +376,98 @@ export class CreditNoteController extends Controller {
update: { value: { increment: 1 } }, update: { value: { increment: 1 } },
}); });
return await prisma.creditNote.create({ return await prisma.creditNote
include: { .create({
requestWork: { include: {
include: { requestWork: {
request: true, include: {
request: true,
},
},
quotation: {
include: {
customerBranch: {
include: {
customer: { include: { branch: { where: { userId: { not: null } } } } },
},
},
},
}, },
}, },
quotation: true, data: {
}, reason: body.reason,
data: { detail: body.detail,
reason: body.reason, remark: body.remark,
detail: body.detail, paybackType: body.paybackType,
remark: body.remark, paybackBank: body.paybackBank,
paybackType: body.paybackType, paybackAccount: body.paybackAccount,
paybackBank: body.paybackBank, paybackAccountName: body.paybackAccountName,
paybackAccount: body.paybackAccount, code: `CN${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${last.value.toString().padStart(6, "0")}`,
paybackAccountName: body.paybackAccountName, value,
code: `CN${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${last.value.toString().padStart(6, "0")}`, requestWork: {
value, connect: body.requestWorkId.map((v) => ({
requestWork: { id: v,
connect: body.requestWorkId.map((v) => ({ })),
id: v, },
})), quotationId: body.quotationId,
}, },
quotationId: body.quotationId, })
}, .then(async (res) => {
}); const token = await this.#getLineToken();
if (!token) return;
const textHead = "JWS ALERT:";
const textAlert = "ขอแจ้งให้ทราบว่าใบลดหนี้";
const textAlert2 = "ได้ถูกสร้างขึ้นเรียบร้อยแล้ว";
const textAlert3 =
"หากท่านต้องการข้อมูลเพิ่มเติมหรือมีข้อสงสัยประการใด โปรดแจ้งให้ฝ่ายที่เกี่ยวข้องทราบ ทางเรายินดีให้ความช่วยเหลืออย่างเต็มที่ 🙏";
let finalTextWork = "";
let textData = "";
let dataCustomerId: string[] = [];
let dataUserId: string[] = [];
if (res) {
res.quotation.customerBranch.customer.branch.forEach((item) => {
if (!dataCustomerId?.includes(item.id) && item.userId) {
dataCustomerId.push(item.id);
dataUserId.push(item.userId);
}
});
finalTextWork = `จำนวนเงิน ${res.value.toFixed(2)} บาท `;
}
textData = `${textHead}\n\n${textAlert}\n${finalTextWork}${textAlert2}\n\n${textAlert3}`;
const data = {
to: dataUserId,
messages: [
{
type: "text",
text: textData,
},
],
};
await fetch("https://api.line.me/v2/bot/message/multicast", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
return res;
});
}, },
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
); );
} }
@Put("{creditNoteId}") @Put("{creditNoteId}")
@Security("keycloak", MANAGE_ROLES) @Security("keycloak")
async updateCreditNote( async updateCreditNote(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Path() creditNoteId: string, @Path() creditNoteId: string,
@ -470,9 +542,8 @@ export class CreditNoteController extends Controller {
).length; ).length;
const price = const price =
c.productService.pricePerUnit - c.productService.pricePerUnit * (1 + (c.productService.vat > 0 ? VAT_DEFAULT : 0)) -
c.productService.discount / c.productService.amount + c.productService.discount;
c.productService.vat / c.productService.amount;
if (serviceChargeStepCount && successCount) { if (serviceChargeStepCount && successCount) {
return a + price - c.productService.product.serviceCharge * successCount; return a + price - c.productService.product.serviceCharge * successCount;
@ -569,6 +640,14 @@ export class CreditNoteActionController extends Controller {
return creditNoteData; return creditNoteData;
} }
async #getLineToken() {
if (!process.env.LINE_MESSAGING_API_TOKEN) {
console.warn("Line Webhook Activated but LINE_MESSAGING_API_TOKEN not set.");
}
return process.env.LINE_MESSAGING_API_TOKEN;
}
@Post("accept") @Post("accept")
@Security("keycloak", MANAGE_ROLES) @Security("keycloak", MANAGE_ROLES)
async acceptCreditNote(@Request() req: RequestWithUser, @Path() creditNoteId: string) { async acceptCreditNote(@Request() req: RequestWithUser, @Path() creditNoteId: string) {
@ -587,23 +666,81 @@ export class CreditNoteActionController extends Controller {
@Body() body: { paybackStatus: PaybackStatus }, @Body() body: { paybackStatus: PaybackStatus },
) { ) {
await this.#checkPermission(req.user, creditNoteId); await this.#checkPermission(req.user, creditNoteId);
return await prisma.creditNote.update({ return await prisma.creditNote
where: { id: creditNoteId }, .update({
include: { where: { id: creditNoteId },
requestWork: { include: {
include: { requestWork: {
request: true, include: {
request: true,
},
},
quotation: {
include: {
customerBranch: {
include: {
customer: { include: { branch: { where: { userId: { not: null } } } } },
},
},
},
}, },
}, },
quotation: true, data: {
}, creditNoteStatus:
data: { body.paybackStatus === PaybackStatus.Done ? CreditNoteStatus.Success : undefined,
creditNoteStatus: paybackStatus: body.paybackStatus,
body.paybackStatus === PaybackStatus.Done ? CreditNoteStatus.Success : undefined, paybackDate: body.paybackStatus === PaybackStatus.Done ? new Date() : undefined,
paybackStatus: body.paybackStatus, },
paybackDate: body.paybackStatus === PaybackStatus.Done ? new Date() : undefined, })
}, .then(async (res) => {
}); const token = await this.#getLineToken();
if (!token) return;
const textHead = "JWS ALERT:";
const textAlert = "ทางเราขอแจ้งให้ทราบว่าการดำเนินการคืนเงินสำหรับใบลดหนี้";
const textAlert2 = "ได้รับการอนุมัติและเสร็จสมบูรณ์เรียบร้อยแล้ว";
const textAlert3 =
"หากท่านต้องการข้อมูลเพิ่มเติมหรือมีข้อสงสัยประการใด โปรดแจ้งให้ฝ่ายที่เกี่ยวข้องทราบ ทางเรายินดีให้ความช่วยเหลืออย่างเต็มที่ 🙏";
let finalTextWork = "";
let textData = "";
let dataCustomerId: string[] = [];
let textWorkList: string[] = [];
let dataUserId: string[] = [];
if (res) {
res.quotation.customerBranch.customer.branch.forEach((item) => {
if (!dataCustomerId?.includes(item.id) && item.userId) {
dataCustomerId.push(item.id);
dataUserId.push(item.userId);
}
});
finalTextWork = `จำนวนเงิน ${res.value.toFixed(2)} บาท `;
}
textData = `${textHead}\n\n${textAlert}\n${finalTextWork}${textAlert2}\n\n${textAlert3}`;
const data = {
to: dataUserId,
messages: [
{
type: "text",
text: textData,
},
],
};
body.paybackStatus === PaybackStatus.Done
? await fetch("https://api.line.me/v2/bot/message/multicast", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
: undefined;
});
} }
} }

View file

@ -36,7 +36,7 @@ import {
setFile, setFile,
} from "../utils/minio"; } from "../utils/minio";
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
import { queryOrNot } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
import { isSystem } from "../utils/keycloak"; import { isSystem } from "../utils/keycloak";
import { precisionRound } from "../utils/arithmetic"; import { precisionRound } from "../utils/arithmetic";
@ -44,22 +44,20 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"head_of_accountant", "executive",
"accountant", "accountant",
"head_of_sale", "branch_admin",
"sale", "branch_manager",
"branch_accountant",
]; ];
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; const listAllowed = ["system", "head_of_admin", "admin", "executive", "accountant"];
return allowList.some((v) => user.roles?.includes(v)); return user.roles?.some((v) => listAllowed.includes(v)) || false;
} }
// NOTE: permission condition/check in registeredBranch
const permissionCond = createPermCondition(globalAllow); const permissionCond = createPermCondition(globalAllow);
const permissionCondCompany = createPermCondition((_) => true);
const permissionCheck = createPermCheck(globalAllow); const permissionCheck = createPermCheck(globalAllow);
const permissionCheckCompany = createPermCheck((_) => true);
type DebitNoteCreate = { type DebitNoteCreate = {
quotationId: string; quotationId: string;
@ -76,6 +74,7 @@ type DebitNoteCreate = {
dateOfBirth: Date; dateOfBirth: Date;
gender: string; gender: string;
nationality: string; nationality: string;
otherNationality?: string | null;
namePrefix?: string; namePrefix?: string;
firstName: string; firstName: string;
firstNameEN: string; firstNameEN: string;
@ -111,13 +110,14 @@ type DebitNoteUpdate = {
dateOfBirth: Date; dateOfBirth: Date;
gender: string; gender: string;
nationality: string; nationality: string;
otherNationality?: string | null;
namePrefix?: string; namePrefix?: string;
firstName: string; firstName?: string;
firstNameEN: string; firstNameEN: string;
middleName?: string; middleName?: string;
middleNameEN?: string; middleNameEN?: string;
lastName: string; lastName?: string;
lastNameEN: string; lastNameEN?: string;
} }
)[]; )[];
@ -168,6 +168,8 @@ export class DebitNoteController extends Controller {
@Query() payCondition?: PayCondition, @Query() payCondition?: PayCondition,
@Query() includeRegisteredBranch?: boolean, @Query() includeRegisteredBranch?: boolean,
@Query() code?: string, @Query() code?: string,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
return await this.getDebitNoteListByCriteria( return await this.getDebitNoteListByCriteria(
req, req,
@ -179,6 +181,8 @@ export class DebitNoteController extends Controller {
payCondition, payCondition,
includeRegisteredBranch, includeRegisteredBranch,
code, code,
startDate,
endDate,
); );
} }
@ -195,21 +199,22 @@ export class DebitNoteController extends Controller {
@Query() payCondition?: PayCondition, @Query() payCondition?: PayCondition,
@Query() includeRegisteredBranch?: boolean, @Query() includeRegisteredBranch?: boolean,
@Query() code?: string, @Query() code?: string,
@Query() startDate?: Date,
@Query() endDate?: Date,
@Body() body?: {}, @Body() body?: {},
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.QuotationWhereInput[]>(query, [ OR: queryOrNot<Prisma.QuotationWhereInput[]>(query, [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ workName: { contains: query } }, { workName: { contains: query, mode: "insensitive" } },
{ {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } },
], ],
}, },
}, },
@ -220,6 +225,7 @@ export class DebitNoteController extends Controller {
debitNoteQuotationId: quotationId, debitNoteQuotationId: quotationId,
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
quotationStatus: status, quotationStatus: status,
...whereDateQuery(startDate, endDate),
} satisfies Prisma.QuotationWhereInput; } satisfies Prisma.QuotationWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -424,12 +430,18 @@ export class DebitNoteController extends Controller {
const list = body.productServiceList.map((v, i) => { const list = body.productServiceList.map((v, i) => {
const p = product.find((p) => p.id === v.productId)!; const p = product.find((p) => p.id === v.productId)!;
const price = body.agentPrice ? p.agentPrice : p.price;
const pricePerUnit = p.vatIncluded ? price / (1 + VAT_DEFAULT) : price; const vatIncluded = body.agentPrice ? p.agentPriceVatIncluded : p.vatIncluded;
const vat = p.calcVat
? (pricePerUnit * (v.discount ? v.amount : 1) - (v.discount || 0)) * const originalPrice = body.agentPrice ? p.agentPrice : p.price;
VAT_DEFAULT * const finalPrice = precisionRound(
(!v.discount ? v.amount : 1) originalPrice + (vatIncluded ? 0 : originalPrice * VAT_DEFAULT),
);
const pricePerUnit = finalPrice / (1 + VAT_DEFAULT);
const vat = (body.agentPrice ? p.agentPriceCalcVat : p.calcVat)
? ((pricePerUnit * (1 + VAT_DEFAULT) * v.amount - (v.discount || 0)) /
(1 + VAT_DEFAULT)) *
VAT_DEFAULT
: 0; : 0;
return { return {
@ -452,15 +464,13 @@ export class DebitNoteController extends Controller {
const price = list.reduce( const price = list.reduce(
(a, c) => { (a, c) => {
a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount); const vat = c.vat ? VAT_DEFAULT : 0;
const price = c.pricePerUnit * c.amount * (1 + vat) - c.discount;
a.totalPrice = precisionRound(a.totalPrice + price / (1 + vat) + c.discount);
a.totalDiscount = precisionRound(a.totalDiscount + c.discount); a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
a.vat = precisionRound(a.vat + c.vat); a.vat = precisionRound(a.vat + c.vat);
a.vatExcluded = a.vatExcluded = c.vat === 0 ? precisionRound(a.vatExcluded + price) : a.vatExcluded;
c.vat === 0
? precisionRound(
a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT,
)
: a.vatExcluded;
a.finalPrice = precisionRound( a.finalPrice = precisionRound(
Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0), Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0),
); );
@ -572,7 +582,7 @@ export class DebitNoteController extends Controller {
} }
@Put("{debitNoteId}") @Put("{debitNoteId}")
@Security("keycloak", MANAGE_ROLES) @Security("keycloak")
async updateDebitNote( async updateDebitNote(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Path() debitNoteId: string, @Path() debitNoteId: string,
@ -596,7 +606,7 @@ export class DebitNoteController extends Controller {
if (!record) throw notFoundError("Debit Note"); if (!record) throw notFoundError("Debit Note");
await permissionCheckCompany(req.user, record.registeredBranch); await permissionCheck(req.user, record.registeredBranch);
const { productServiceList: _productServiceList, ...rest } = body; const { productServiceList: _productServiceList, ...rest } = body;
const ids = { const ids = {
@ -667,12 +677,18 @@ export class DebitNoteController extends Controller {
} }
const list = body.productServiceList.map((v, i) => { const list = body.productServiceList.map((v, i) => {
const p = product.find((p) => p.id === v.productId)!; const p = product.find((p) => p.id === v.productId)!;
const price = body.agentPrice ? p.agentPrice : p.price;
const pricePerUnit = p.vatIncluded ? price / (1 + VAT_DEFAULT) : price; const vatIncluded = record.agentPrice ? p.agentPriceVatIncluded : p.vatIncluded;
const vat = p.calcVat
? (pricePerUnit * (v.discount ? v.amount : 1) - (v.discount || 0)) * const originalPrice = record.agentPrice ? p.agentPrice : p.price;
VAT_DEFAULT * const finalPrice = precisionRound(
(!v.discount ? v.amount : 1) originalPrice + (vatIncluded ? 0 : originalPrice * VAT_DEFAULT),
);
const pricePerUnit = finalPrice / (1 + VAT_DEFAULT);
const vat = (record.agentPrice ? p.agentPriceCalcVat : p.calcVat)
? ((pricePerUnit * (1 + VAT_DEFAULT) * v.amount - (v.discount || 0)) /
(1 + VAT_DEFAULT)) *
VAT_DEFAULT
: 0; : 0;
return { return {
@ -695,15 +711,13 @@ export class DebitNoteController extends Controller {
const price = list.reduce( const price = list.reduce(
(a, c) => { (a, c) => {
a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount); const vat = c.vat ? VAT_DEFAULT : 0;
const price = c.pricePerUnit * c.amount * (1 + vat) - c.discount;
a.totalPrice = precisionRound(a.totalPrice + price / (1 + vat) + c.discount);
a.totalDiscount = precisionRound(a.totalDiscount + c.discount); a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
a.vat = precisionRound(a.vat + c.vat); a.vat = precisionRound(a.vat + c.vat);
a.vatExcluded = a.vatExcluded = c.vat === 0 ? precisionRound(a.vatExcluded + price) : a.vatExcluded;
c.vat === 0
? precisionRound(
a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT,
)
: a.vatExcluded;
a.finalPrice = precisionRound( a.finalPrice = precisionRound(
Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0), Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0),
); );

View file

@ -25,7 +25,7 @@ import {
TaskStatus, TaskStatus,
RequestWorkStatus, RequestWorkStatus,
} from "@prisma/client"; } from "@prisma/client";
import { queryOrNot, whereAddressQuery } from "../utils/relation"; import { queryOrNot, whereAddressQuery, whereDateQuery } from "../utils/relation";
import { filterStatus } from "../services/prisma"; import { filterStatus } from "../services/prisma";
// import { RequestWorkStatus } from "../generated/kysely/types"; // import { RequestWorkStatus } from "../generated/kysely/types";
import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio"; import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio";
@ -51,6 +51,8 @@ export class LineController extends Controller {
@Query() page: number = 1, @Query() page: number = 1,
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() activeOnly?: boolean, @Query() activeOnly?: boolean,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: !!query OR: !!query
@ -58,13 +60,13 @@ export class LineController extends Controller {
...(queryOrNot<Prisma.EmployeeWhereInput[]>(query, [ ...(queryOrNot<Prisma.EmployeeWhereInput[]>(query, [
{ {
employeePassport: { employeePassport: {
some: { number: { contains: query } }, some: { number: { contains: query, mode: "insensitive" } },
}, },
}, },
{ firstName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
...whereAddressQuery(query), ...whereAddressQuery(query),
]) ?? []), ]) ?? []),
] ]
@ -87,6 +89,7 @@ export class LineController extends Controller {
subDistrict: zipCode ? { zipCode } : undefined, subDistrict: zipCode ? { zipCode } : undefined,
gender, gender,
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.EmployeeWhereInput; } satisfies Prisma.EmployeeWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -173,24 +176,25 @@ export class LineController extends Controller {
@Query() requestDataStatus?: RequestDataStatus, @Query() requestDataStatus?: RequestDataStatus,
@Query() quotationId?: string, @Query() quotationId?: string,
@Query() code?: string, @Query() code?: string,
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.RequestDataWhereInput[]>(query, [ OR: queryOrNot<Prisma.RequestDataWhereInput[]>(query, [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ quotation: { code: { contains: query, mode: "insensitive" } } }, { quotation: { code: { contains: query, mode: "insensitive" } } },
{ quotation: { workName: { contains: query } } }, { quotation: { workName: { contains: query, mode: "insensitive" } } },
{ {
quotation: { quotation: {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query } }, { registerName: { contains: query, mode: "insensitive" } },
{ registerName: { contains: query } }, { registerNameEN: { contains: query, mode: "insensitive" } },
{ registerNameEN: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } },
], ],
}, },
}, },
@ -198,14 +202,14 @@ export class LineController extends Controller {
OR: [ OR: [
{ {
employeePassport: { employeePassport: {
some: { number: { contains: query } }, some: { number: { contains: query, mode: "insensitive" } },
}, },
}, },
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ lastNameEN: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
], ],
}, },
}, },
@ -247,6 +251,7 @@ export class LineController extends Controller {
], ],
}, },
}, },
...whereDateQuery(startDate, endDate),
} satisfies Prisma.RequestDataWhereInput; } satisfies Prisma.RequestDataWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -604,41 +609,26 @@ export class LineController extends Controller {
@Query() includeRegisteredBranch?: boolean, @Query() includeRegisteredBranch?: boolean,
@Query() code?: string, @Query() code?: string,
@Query() query = "", @Query() query = "",
@Query() startDate?: Date,
@Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: OR: queryOrNot<Prisma.QuotationWhereInput[]>(query, [
query || pendingOnly { code: { contains: query, mode: "insensitive" } },
? [ { workName: { contains: query, mode: "insensitive" } },
...(queryOrNot<Prisma.QuotationWhereInput[]>(query, [ {
{ code: { contains: query, mode: "insensitive" } }, customerBranch: {
{ workName: { contains: query } }, OR: [
{ { code: { contains: query, mode: "insensitive" } },
customerBranch: { { registerName: { contains: query, mode: "insensitive" } },
OR: [ { firstName: { contains: query, mode: "insensitive" } },
{ code: { contains: query, mode: "insensitive" } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query } }, { lastName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query } }, { lastNameEN: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query } }, ],
{ lastName: { contains: query } }, },
{ lastNameEN: { contains: query } }, },
], ]),
},
},
]) || []),
...(queryOrNot<Prisma.QuotationWhereInput[]>(!!pendingOnly, [
{
requestData: {
some: {
requestDataStatus: "Pending",
},
},
},
{
requestData: { none: {} },
},
]) || []),
]
: undefined,
isDebitNote: false, isDebitNote: false,
code, code,
payCondition, payCondition,
@ -660,6 +650,23 @@ export class LineController extends Controller {
}, },
} }
: undefined, : undefined,
AND: pendingOnly
? {
OR: [
{
requestData: {
some: {
requestDataStatus: "Pending",
},
},
},
{
requestData: { none: {} },
},
],
}
: undefined,
...whereDateQuery(startDate, endDate),
} satisfies Prisma.QuotationWhereInput; } satisfies Prisma.QuotationWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -1368,3 +1375,65 @@ export class LineQuotationFileController extends Controller {
return await deleteFile(fileLocation.quotation.attachment(quotationId, name)); return await deleteFile(fileLocation.quotation.attachment(quotationId, name));
} }
} }
@Route("api/v1/line/payment/{paymentId}/attachment")
@Tags("Line")
export class PaymentFileLineController extends Controller {
private async checkPermission(_user: RequestWithUser["user"], id: string) {
const data = await prisma.payment.findUnique({
include: {
invoice: {
include: {
quotation: true,
},
},
},
where: { id },
});
if (!data) throw notFoundError("Payment");
return { paymentId: id, quotationId: data.invoice.quotationId };
}
@Get()
@Security("line")
async listAttachment(@Request() req: RequestWithUser, @Path() paymentId: string) {
const { quotationId } = await this.checkPermission(req.user, paymentId);
return await listFile(fileLocation.quotation.payment(quotationId, paymentId));
}
@Head("{name}")
async headAttachment(
@Request() req: RequestWithUser,
@Path() paymentId: string,
@Path() name: string,
) {
const data = await prisma.payment.findUnique({
where: { id: paymentId },
include: { invoice: true },
});
if (!data) throw notFoundError("Payment");
return req.res?.redirect(
await getPresigned(
"head",
fileLocation.quotation.payment(data.invoice.quotationId, paymentId, name),
),
);
}
@Get("{name}")
async getAttachment(
@Request() req: RequestWithUser,
@Path() paymentId: string,
@Path() name: string,
) {
const data = await prisma.payment.findUnique({
where: { id: paymentId },
include: { invoice: true },
});
if (!data) throw notFoundError("Payment");
return req.res?.redirect(
await getFile(fileLocation.quotation.payment(data.invoice.quotationId, paymentId, name)),
);
}
}

View file

@ -90,7 +90,7 @@ export class WebHookController extends Controller {
firstNameEN: true, firstNameEN: true,
lastName: true, lastName: true,
lastNameEN: true, lastNameEN: true,
customerName: true, registerName: true,
customer: { customer: {
select: { select: {
customerType: true, customerType: true,
@ -133,13 +133,13 @@ export class WebHookController extends Controller {
let textData = ""; let textData = "";
if (dataEmployee.length > 0) { if (dataEmployee.length > 0) {
const customerName = const registerName =
dataEmployee[0]?.employee?.customerBranch?.customerName ?? "ไม่ระบุ"; dataEmployee[0]?.employee?.customerBranch?.registerName ?? "ไม่ระบุ";
const telephoneNo = const telephoneNo =
dataEmployee[0]?.employee?.customerBranch?.customer.registeredBranch.telephoneNo ?? dataEmployee[0]?.employee?.customerBranch?.customer.registeredBranch.telephoneNo ??
"ไม่ระบุ"; "ไม่ระบุ";
const textEmployer = `เรียน คุณ${customerName}`; const textEmployer = `เรียน คุณ${registerName}`;
const textAlert = "ขอแจ้งให้ทราบว่าหนังสือเดินทางของลูกจ้าง"; const textAlert = "ขอแจ้งให้ทราบว่าหนังสือเดินทางของลูกจ้าง";
const textAlert2 = "และจำเป็นต้องดำเนินการต่ออายุในเร็ว ๆ นี้"; const textAlert2 = "และจำเป็นต้องดำเนินการต่ออายุในเร็ว ๆ นี้";
const textExpDate = const textExpDate =

View file

@ -0,0 +1,113 @@
import {
Body,
Controller,
Delete,
Get,
Path,
Post,
Put,
Query,
Request,
Route,
Security,
Tags,
} from "tsoa";
import { RequestWithUser } from "../interfaces/user";
import prisma from "../db";
import { Prisma } from "@prisma/client";
import { queryOrNot } from "../utils/relation";
import { notFoundError } from "../utils/error";
type BusinessTypePayload = {
name: string;
nameEN: string;
};
@Route("api/v1/business-type")
@Tags("Business Type")
export class businessTypeController extends Controller {
@Get()
@Security("keycloak")
async getList(
@Request() req: RequestWithUser,
@Query() query: string = "",
@Query() page: number = 1,
@Query() pageSize: number = 30,
) {
const where = {
OR: queryOrNot<Prisma.BusinessTypeWhereInput[]>(query, [
{ name: { contains: query, mode: "insensitive" } },
{ nameEN: { contains: query, mode: "insensitive" } },
]),
} satisfies Prisma.BusinessTypeWhereInput;
const [result, total] = await prisma.$transaction([
prisma.businessType.findMany({
where,
take: pageSize,
skip: (page - 1) * pageSize,
}),
prisma.businessType.count({ where }),
]);
return { result, page, pageSize, total };
}
@Post()
@Security("keycloak")
async createBusinessType(@Request() req: RequestWithUser, @Body() body: BusinessTypePayload) {
return await prisma.businessType.create({
data: {
...body,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
}
@Get(":businessTypeId")
@Security("keycloak")
async getBusinessTypeById(@Path() businessTypeId: string) {
return await prisma.businessType.findUnique({
where: { id: businessTypeId },
});
}
@Put(":businessTypeId")
@Security("keycloak")
async updateBusinessType(
@Request() req: RequestWithUser,
@Path() businessTypeId: string,
@Body() body: BusinessTypePayload,
) {
return await prisma.$transaction(async (tx) => {
const record = await tx.businessType.findUnique({
where: { id: businessTypeId },
});
if (!record) throw notFoundError("BusinessType");
return await tx.businessType.update({
where: { id: businessTypeId },
data: {
...body,
updatedByUserId: req.user.sub,
},
});
});
}
@Delete(":businessTypeId")
@Security("keycloak")
async deleteBusinessType(@Path() businessTypeId: string) {
return await prisma.$transaction(async (tx) => {
const record = await tx.businessType.findUnique({
where: { id: businessTypeId },
});
if (!record) throw notFoundError("BusinessType");
return await tx.businessType.delete({
where: { id: businessTypeId },
});
});
}
}

View file

@ -0,0 +1,25 @@
import express from "express";
import { Controller, Get, Path, Request, Route } from "tsoa";
import { getFile } from "../utils/minio";
@Route("api/v1/troubleshooting")
export class TroubleshootingController extends Controller {
@Get()
async get(@Request() req: express.Request) {
return req.res?.redirect(await getFile(".troubleshooting/toc.json"));
}
@Get("{category}/assets/{name}")
async getAsset(@Request() req: express.Request, @Path() category: string, @Path() name: string) {
return req.res?.redirect(await getFile(`.troubleshooting/${category}/assets/${name}`));
}
@Get("{category}/page/{page}")
async getContent(
@Request() req: express.Request,
@Path() category: string,
@Path() page: string,
) {
return req.res?.redirect(await getFile(`.troubleshooting/${category}/${page}.md`));
}
}

View file

@ -1,6 +1,10 @@
import prisma from "../db"; import prisma from "../db";
import config from "../config.json"; import config from "../config.json";
import { CustomerType, PayCondition } from "@prisma/client"; import { CustomerType, PayCondition } from "@prisma/client";
import { convertTemplate } from "../utils/string-template";
import { htmlToText } from "html-to-text";
import { JsonObject } from "@prisma/client/runtime/library";
import { precisionRound } from "../utils/arithmetic";
if (!process.env.FLOW_ACCOUNT_URL) throw new Error("Require FLOW_ACCOUNT_URL"); if (!process.env.FLOW_ACCOUNT_URL) throw new Error("Require FLOW_ACCOUNT_URL");
if (!process.env.FLOW_ACCOUNT_CLIENT_ID) throw new Error("Require FLOW_ACCOUNT_CLIENT_ID"); if (!process.env.FLOW_ACCOUNT_CLIENT_ID) throw new Error("Require FLOW_ACCOUNT_CLIENT_ID");
@ -232,6 +236,29 @@ const flowAccount = {
installments: true, installments: true,
quotation: { quotation: {
include: { include: {
paySplit: true,
worker: {
select: {
employee: {
select: {
employeePassport: {
select: {
number: true,
},
orderBy: {
expireDate: "desc",
},
take: 1,
},
namePrefix: true,
firstName: true,
lastName: true,
firstNameEN: true,
lastNameEN: true,
},
},
},
},
registeredBranch: { registeredBranch: {
include: { include: {
province: true, province: true,
@ -262,19 +289,58 @@ const flowAccount = {
const quotation = data.quotation; const quotation = data.quotation;
const customer = quotation.customerBranch; const customer = quotation.customerBranch;
const product =
const summary = {
subTotal: 0,
discountAmount: 0,
vatableAmount: 0,
exemptAmount: 0,
vatAmount: 0,
grandTotal: 0,
};
const products = (
quotation.payCondition === PayCondition.BillFull || quotation.payCondition === PayCondition.BillFull ||
quotation.payCondition === PayCondition.Full quotation.payCondition === PayCondition.Full
? quotation.productServiceList ? quotation.productServiceList
: quotation.productServiceList.filter((lhs) => : quotation.productServiceList.filter((lhs) =>
data.installments.some((rhs) => rhs.no === lhs.installmentNo), data.installments.some((rhs) => rhs.no === lhs.installmentNo),
); )
).map((v) => {
// TODO: Use product's VAT field (not implemented) instead.
const VAT_RATE = VAT_DEFAULT;
summary.subTotal +=
precisionRound(v.pricePerUnit * (1 + (v.vat > 0 ? VAT_RATE : 0))) * v.amount;
summary.discountAmount += v.discount;
const total =
precisionRound(v.pricePerUnit * (1 + (v.vat > 0 ? VAT_RATE : 0))) * v.amount -
(v.discount ?? 0);
if (v.vat > 0) {
summary.vatableAmount += precisionRound(total / (1 + VAT_RATE));
summary.vatAmount += v.vat;
} else {
summary.exemptAmount += total;
}
summary.grandTotal += total;
return {
type: ProductAndServiceType.ProductNonInv,
name: v.product.name,
pricePerUnit: precisionRound(v.pricePerUnit),
quantity: v.amount,
discountAmount: v.discount,
vatRate: v.vat === 0 ? 0 : Math.round(VAT_RATE * 100),
total,
};
});
const payload = { const payload = {
contactCode: customer.code, contactCode: customer.code,
contactName: contactName: customer.contactName || "-",
(customer.customer.customerType === CustomerType.PERS
? [customer.firstName, customer.lastName].join(" ").trim()
: customer.registerName) || "-",
contactAddress: [ contactAddress: [
customer.address, customer.address,
!!customer.moo ? "หมู่ " + customer.moo : null, !!customer.moo ? "หมู่ " + customer.moo : null,
@ -283,11 +349,10 @@ const flowAccount = {
(customer.province?.id === "10" ? "แขวง" : "อำเภอ") + customer.subDistrict?.name, (customer.province?.id === "10" ? "แขวง" : "อำเภอ") + customer.subDistrict?.name,
(customer.province?.id === "10" ? "เขต" : "ตำบล") + customer.district?.name, (customer.province?.id === "10" ? "เขต" : "ตำบล") + customer.district?.name,
"จังหวัด" + customer.province?.name, "จังหวัด" + customer.province?.name,
customer.subDistrict?.zipCode,
] ]
.filter(Boolean) .filter(Boolean)
.join(" "), .join(" "),
contactTaxId: customer.citizenId || customer.code, contactTaxId: customer.citizenId || customer.legalPersonNo || "-",
contactBranch: contactBranch:
(customer.customer.customerType === CustomerType.PERS (customer.customer.customerType === CustomerType.PERS
? [customer.firstName, customer.lastName].join(" ").trim() ? [customer.firstName, customer.lastName].join(" ").trim()
@ -305,36 +370,35 @@ const flowAccount = {
isVat: true, isVat: true,
useReceiptDeduction: false, useReceiptDeduction: false,
useInlineVat: true,
discounPercentage: 0, discounPercentage: 0,
discountAmount: quotation.totalDiscount, discountAmount: quotation.totalDiscount,
subTotal: subTotal: summary.subTotal,
quotation.payCondition === "BillSplitCustom" || quotation.payCondition === "SplitCustom" totalAfterDiscount: summary.subTotal - summary.discountAmount,
? 0 vatableAmount: summary.vatableAmount,
: quotation.totalPrice, exemptAmount: summary.exemptAmount,
totalAfterDiscount: vatAmount: summary.vatAmount,
quotation.payCondition === "BillSplitCustom" || quotation.payCondition === "SplitCustom" grandTotal: summary.grandTotal,
? 0
: quotation.finalPrice,
vatAmount:
quotation.payCondition === "BillSplitCustom" || quotation.payCondition === "SplitCustom"
? 0
: quotation.vat,
grandTotal:
quotation.payCondition === "BillSplitCustom" || quotation.payCondition === "SplitCustom"
? data.installments.reduce((a, c) => a + c.amount, 0)
: quotation.finalPrice,
items: product.map((v) => ({ remarks: htmlToText(
type: ProductAndServiceType.ProductNonInv, convertTemplate(quotation.remark ?? "", {
name: v.product.name, "quotation-payment": {
pricePerUnit: v.pricePerUnit, paymentType: quotation?.payCondition || "Full",
quantity: v.amount, amount: quotation.finalPrice,
discountAmount: v.discount, installments: quotation?.paySplit,
total: (v.pricePerUnit - (v.discount || 0)) * v.amount + v.vat, },
vatRate: v.vat === 0 ? 0 : Math.round(VAT_DEFAULT * 100), "quotation-labor": {
})), name: quotation.worker.map(
(v, i) =>
`${i + 1}. ` +
`${v.employee.employeePassport.length !== 0 ? v.employee.employeePassport[0].number + "_" : ""}${v.employee.namePrefix}. ${v.employee.firstNameEN ? `${v.employee.firstNameEN} ${v.employee.lastNameEN}` : `${v.employee.firstName} ${v.employee.lastName}`} `.toUpperCase(),
),
},
}),
),
items: products,
}; };
return await flowAccountAPI.createReceipt(payload, false); return await flowAccountAPI.createReceipt(payload, false);
@ -347,6 +411,219 @@ const flowAccount = {
} }
return null; return null;
}, },
// flowAccount GET Product list
async getProducts() {
const { token } = await flowAccountAPI.auth();
const res = await fetch(api + "/products", {
method: "GET",
headers: {
["Content-Type"]: `application/json`,
["Authorization"]: `Bearer ${token}`,
},
});
return {
ok: res.ok,
status: res.status,
body: await res.json(),
};
},
// flowAccount GET Product by id
async getProductsById(recordId: string) {
const { token } = await flowAccountAPI.auth();
const res = await fetch(api + `/products/${recordId}`, {
method: "GET",
headers: {
["Content-Type"]: `application/json`,
["Authorization"]: `Bearer ${token}`,
},
});
const data = await res.json();
return {
ok: res.ok,
status: res.status,
list: data.data.list,
total: data.data.total,
};
},
// flowAccount POST create Product
async createProducts(code: string, body: JsonObject) {
const { token } = await flowAccountAPI.auth();
const commonBody = {
productStructureType: null,
type: 3,
name: body.name,
sellDescription: body.detail,
sellVatType: 3,
buyPrice: body.serviceCharge,
buyVatType: body.serviceChargeVatIncluded ? 1 : 3,
buyDescription: body.detail,
};
const createProduct = async (name: string, price: any, vatIncluded: boolean) => {
try {
const res = await fetch(`${api}/products`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
...commonBody,
name,
sellPrice: price,
sellVatType: vatIncluded ? 1 : 3,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: Failed to create product`);
const json = await res.json().catch(() => {
throw new Error("Invalid JSON response from FlowAccount API");
});
return json?.data?.list?.[0]?.id ?? null;
} catch (err) {
console.error("createProduct error:", err);
return null;
}
};
const deleteProduct = async (id: string) => {
try {
await fetch(`${api}/products/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch (err) {
console.error("Rollback delete failed:", err);
}
};
const [sellResult, agentResult] = await Promise.allSettled([
createProduct(`${code} ${body.name}`, body.price, /true/.test(`${body.vatIncluded}`)),
createProduct(
`${code} ${body.name} (ราคาตัวแทน)`,
body.agentPrice,
/true/.test(`${body.agentPriceVatIncluded}`),
),
]);
const sellId = sellResult.status === "fulfilled" ? sellResult.value : null;
const agentId = agentResult.status === "fulfilled" ? agentResult.value : null;
// --- validation ---
if (!sellId && !agentId) {
throw new Error("FlowAccountProductError.BOTH_CREATION_FAILED");
}
if (!sellId && agentId) {
await deleteProduct(agentId);
throw new Error("FlowAccountProductError.SELL_PRICE_CREATION_FAILED");
}
if (sellId && !agentId) {
await deleteProduct(sellId);
throw new Error("FlowAccountProductError.AGENT_PRICE_CREATION_FAILED");
}
return {
ok: true,
status: 200,
data: {
productIdSellPrice: sellId,
productIdAgentPrice: agentId,
},
};
},
// flowAccount PUT edit Product
async editProducts(sellPriceId: String, agentPriceId: String, body: JsonObject) {
const { token } = await flowAccountAPI.auth();
const commonBody = {
productStructureType: null,
type: 3,
name: body.name,
sellDescription: body.detail,
sellVatType: 3,
buyPrice: body.serviceCharge,
buyVatType: body.serviceChargeVatIncluded ? 1 : 3,
buyDescription: body.detail,
};
const editProduct = async (id: String, name: String, price: any, vatIncluded: boolean) => {
try {
const res = await fetch(api + `/products/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
...commonBody,
name: name,
sellPrice: price,
sellVatType: vatIncluded ? 1 : 3,
}),
});
if (!res.ok) {
throw new Error(`Request failed with status ${res.status} ${res}`);
}
let json: any = null;
try {
json = await res.json();
} catch {
throw new Error("Response is not valid JSON");
}
return json?.data?.list?.[0]?.id ?? null;
} catch (err) {
console.error("createProduct error:", err);
return null;
}
};
await Promise.all([
editProduct(
sellPriceId,
`${body.code} ${body.name}`,
body.price,
/true/.test(`${body.vatIncluded}`),
),
editProduct(
agentPriceId,
`${body.code} ${body.name} (ราคาตัวแทน)`,
body.agentPrice,
/true/.test(`${body.agentPriceVatIncluded}`),
),
]);
},
// flowAccount DELETE Product
async deleteProduct(recordId: string) {
const { token } = await flowAccountAPI.auth();
const res = await fetch(api + `/products/${recordId}`, {
method: "DELETE",
headers: {
["Authorization"]: `Bearer ${token}`,
},
});
return {
ok: res.ok,
status: res.status,
};
},
}; };
export default flowAccount; export default flowAccount;

View file

@ -346,6 +346,64 @@ export async function removeUserRoles(userId: string, roles: { id: string; name:
return true; return true;
} }
export async function getGroup(query: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/groups?${query}`, {
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
method: "GET",
});
const dataMainGroup = await res.json();
const fetchSubGroups = async (group: any) => {
let fullSubGroup = await Promise.all(
group.subGroups.map((subGroupsData: any) => {
if (group.subGroupCount > 0) {
return fetchSubGroups(subGroupsData);
} else {
return {
id: subGroupsData.id,
name: subGroupsData.name,
path: subGroupsData.path,
subGroupCount: subGroupsData.subGroupCount,
subGroups: [],
};
}
}),
);
return {
id: group.id,
name: group.name,
path: group.path,
subGroupCount: group.subGroupCount,
subGroups: fullSubGroup,
};
};
const fullMainGroup = await Promise.all(dataMainGroup.map(fetchSubGroups));
return fullMainGroup;
}
export async function getGroupUser(userId: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/groups`, {
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
method: "GET",
});
const data = await res.json();
return data.map((item: any) => {
return {
id: item.id,
name: item.name,
path: item.path,
};
});
}
export default { export default {
createUser, createUser,
listRole, listRole,

View file

@ -2,6 +2,7 @@ import dayjs from "dayjs";
import { CronJob } from "cron"; import { CronJob } from "cron";
import prisma from "../db"; import prisma from "../db";
import { Prisma } from "@prisma/client";
const jobs = [ const jobs = [
CronJob.from({ CronJob.from({
@ -38,6 +39,162 @@ const jobs = [
.catch((e) => console.error("[ERR]: Update expired quotation status, FAILED.", e)); .catch((e) => console.error("[ERR]: Update expired quotation status, FAILED.", e));
}, },
}), }),
CronJob.from({
cronTime: "0 0 0 * * *",
runOnInit: true,
onTick: async () => {
const employeeExpireData = await prisma.employee.findMany({
include: {
employeePassport: {
orderBy: {
expireDate: "desc",
},
take: 1,
},
customerBranch: {
include: {
customer: true,
},
},
quotationWorker: {
include: {
quotation: true,
},
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
where: {
employeePassport: {
some: {
expireDate: dayjs().add(90, "day").toDate(),
},
},
},
});
await Promise.all(
employeeExpireData.map(async (record) => {
const fullName = `${record.namePrefix}.${record.firstNameEN} ${record.lastNameEN}`;
const expireDate = `${dayjs(record.employeePassport[0].expireDate).format("DD/MM")}/${dayjs(record.employeePassport[0].expireDate).year() + 543}`;
const textDetail = `ลูกจ้างรหัส / code : ${record.code} ชื่อ : ${fullName} หนังสือเดินทางจะหมดอายุในวันที่ ${expireDate}`;
const duplicateText = await prisma.notification.findFirst({
where: {
detail: textDetail,
},
});
const dataNotification: Prisma.NotificationCreateArgs["data"] = {
title: "หนังสือเดินทางลูกจ้างหมดอายุ / Employee Passport Expire",
detail: textDetail,
};
if (record.quotationWorker && record.quotationWorker.length > 0) {
dataNotification.receiverId = record.quotationWorker[0].quotation.updatedByUserId;
dataNotification.registeredBranchId =
record.quotationWorker[0].quotation.registeredBranchId;
} else {
(dataNotification.groupReceiver = {
create: [{ name: "sale" }, { name: "head_of_sale" }],
}),
(dataNotification.registeredBranchId =
record.customerBranch.customer.registeredBranchId);
}
if (!duplicateText) {
await prisma.notification
.create({
data: dataNotification,
})
.then(() => console.log("[INFO]: Create notification employee passport expired, OK."))
.catch((e) =>
console.error("[ERR]: Create notification employee passport expired, FAILED.", e),
);
}
}),
);
},
}),
CronJob.from({
cronTime: "0 0 0 * * *",
runOnInit: true,
onTick: async () => {
const employeeVisaData = await prisma.employee.findMany({
include: {
employeeVisa: {
orderBy: {
expireDate: "desc",
},
take: 1,
},
customerBranch: {
include: {
customer: true,
},
},
quotationWorker: {
include: {
quotation: true,
},
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
where: {
employeeVisa: {
some: {
expireDate: dayjs().add(90, "day").toDate(),
},
},
},
});
await Promise.all(
employeeVisaData.map(async (record) => {
const fullName = `${record.namePrefix}.${record.firstNameEN} ${record.lastNameEN}`;
const expireDate = `${dayjs(record.employeeVisa[0].expireDate).format("DD/MM")}/${dayjs(record.employeeVisa[0].expireDate).year() + 543}`;
const textDetail = `ลูกจ้างรหัส / code : ${record.code} ชื่อ : ${fullName} ข้อมูลการตรวจลงตราจะหมดอายุในวันที่ ${expireDate}`;
const duplicateText = await prisma.notification.findFirst({
where: {
detail: textDetail,
},
});
const dataNotification: Prisma.NotificationCreateArgs["data"] = {
title: "ข้อมูลการตรวจลงตราลูกจ้างหมดอายุ / Employee Visa Expire",
detail: textDetail,
};
if (record.quotationWorker && record.quotationWorker.length > 0) {
dataNotification.receiverId = record.quotationWorker[0].quotation.updatedByUserId;
dataNotification.registeredBranchId =
record.quotationWorker[0].quotation.registeredBranchId;
} else {
(dataNotification.groupReceiver = {
create: [{ name: "sale" }, { name: "head_of_sale" }],
}),
(dataNotification.registeredBranchId =
record.customerBranch.customer.registeredBranchId);
}
if (!duplicateText) {
await prisma.notification
.create({
data: dataNotification,
})
.then(() => console.log("[INFO]: Create notification employee visa expired, OK."))
.catch((e) =>
console.error("[ERR]: Create notification employee visa expired, FAILED.", e),
);
}
}),
);
},
}),
]; ];
export function initSchedule() { export function initSchedule() {

View file

@ -127,6 +127,8 @@ export const fileLocation = {
`${ROOT}/institution/attachment-${institutionId}/${name || ""}`, `${ROOT}/institution/attachment-${institutionId}/${name || ""}`,
img: (institutionId: string, name?: string) => img: (institutionId: string, name?: string) =>
`${ROOT}/institution/img-${institutionId}/${name || ""}`, `${ROOT}/institution/img-${institutionId}/${name || ""}`,
bank: (institutionId: string, bankId: string) =>
`${ROOT}/institution/bank-qr-${institutionId}-${bankId}`,
}, },
task: { task: {
attachment: (taskId: string, name?: string) => attachment: (taskId: string, name?: string) =>

View file

@ -10,26 +10,35 @@ export function connectOrDisconnect(id?: string | null) {
export function whereAddressQuery(query: string) { export function whereAddressQuery(query: string) {
return [ return [
{ address: { contains: query } }, { address: { contains: query, mode: "insensitive" } },
{ addressEN: { contains: query } }, { addressEN: { contains: query, mode: "insensitive" } },
{ soi: { contains: query } }, { soi: { contains: query, mode: "insensitive" } },
{ soiEN: { contains: query } }, { soiEN: { contains: query, mode: "insensitive" } },
{ moo: { contains: query } }, { moo: { contains: query, mode: "insensitive" } },
{ mooEN: { contains: query } }, { mooEN: { contains: query, mode: "insensitive" } },
{ street: { contains: query } }, { street: { contains: query, mode: "insensitive" } },
{ streetEN: { contains: query } }, { streetEN: { contains: query, mode: "insensitive" } },
{ province: { name: { contains: query } } }, { province: { name: { contains: query, mode: "insensitive" } } },
{ province: { nameEN: { contains: query } } }, { province: { nameEN: { contains: query, mode: "insensitive" } } },
{ district: { name: { contains: query } } }, { district: { name: { contains: query, mode: "insensitive" } } },
{ district: { nameEN: { contains: query } } }, { district: { nameEN: { contains: query, mode: "insensitive" } } },
{ subDistrict: { name: { contains: query } } }, { subDistrict: { name: { contains: query, mode: "insensitive" } } },
{ subDistrict: { nameEN: { contains: query } } }, { subDistrict: { nameEN: { contains: query, mode: "insensitive" } } },
{ subDistrict: { zipCode: { contains: query } } }, { subDistrict: { zipCode: { contains: query, mode: "insensitive" } } },
]; ] as const;
} }
export function queryOrNot<T>(query: string | boolean, where: T): T | undefined; export function queryOrNot<T>(query: any, where: T): T | undefined;
export function queryOrNot<T, U>(query: string | boolean, where: T, fallback: U): T | U; export function queryOrNot<T, U>(query: any, where: T, fallback: U): T | U;
export function queryOrNot<T, U>(query: string | boolean, where: T, fallback?: U) { export function queryOrNot<T, U>(query: any, where: T, fallback?: U) {
return !!query ? where : fallback; return !!query ? where : fallback;
} }
export function whereDateQuery(startDate: Date | undefined, endDate: Date | undefined) {
return {
createdAt: {
gte: startDate,
lte: endDate,
},
};
}

105
src/utils/spreadsheet.ts Normal file
View file

@ -0,0 +1,105 @@
import Excel from "exceljs";
export default class spreadsheet {
static async readCsv() {
// TODO: read csv
}
/**
* This function read data from excel file.
*
* @param buffer - Excel file.
* @param opts.header - Interprets the first row as the names of the fields.
* @param opts.worksheet - Specifies the worksheet to read. Can be the worksheet's name or its 1-based index.
*
* @returns
*/
static async readExcel<T extends unknown>(
buffer: Excel.Buffer,
opts?: { header?: boolean; worksheet?: number | string },
): Promise<T[]> {
const workbook = new Excel.Workbook();
await workbook.xlsx.load(buffer);
const worksheet = workbook.getWorksheet(opts?.worksheet ?? 1);
if (!worksheet) return [];
const header: Record<number, string | number> = {};
const values: any[] = [];
worksheet.eachRow((row, rowId) => {
if (rowId === 1 && opts?.header !== false) {
row.eachCell((cell, cellId) => {
if (typeof cell.value === "string") {
header[cellId] = nameValue(cell.value);
} else {
header[cellId] = cellId.toString();
}
});
} else {
const data: Record<string | number, Excel.CellValue> = {};
row.eachCell((cell, cellId) => {
data[opts?.header !== false ? header[cellId] : cellId - 1] = cell.value;
});
values.push(opts?.header !== false ? data : Object.values(data));
}
});
return values;
}
}
function nameValue(value: string) {
let code: string;
switch (value) {
case "ชื่อสินค้าและบริการ":
code = "name";
break;
case "ระยะเวลาดำเนินการ":
code = "process";
break;
case "ประเภทค่าใช้จ่าย":
code = "expenseType";
break;
case "รายละเอียด":
code = "detail";
break;
case "หมายเหตุ":
code = "remark";
break;
case "ใช้งานร่วมกัน":
code = "shared";
break;
case "คำนวณภาษีราคาขาย":
code = "calcVat";
break;
case "รวม VAT ราคาขาย":
code = "vatIncluded";
break;
case "ราคาต่อหน่วย (บาท) ราคาขาย":
code = "price";
break;
case "คำนวณภาษีราคาตัวแทน":
code = "agentPriceCalcVat";
break;
case "รวม VAT ราคาตัวแทน":
code = "agentPriceVatIncluded";
break;
case "ราคาต่อหน่วย (บาท) ราคาตัวแทน":
code = "agentPrice";
break;
case "คำนวณภาษีราคาดำเนินการ":
code = "serviceChargeCalcVat";
break;
case "รวม VAT ราคาดำเนินการ":
code = "serviceChargeVatIncluded";
break;
case "ราคาต่อหน่วย (บาท) ราคาดำเนินการ":
code = "serviceCharge";
break;
default:
code = "code";
break;
}
return code;
}

View file

@ -0,0 +1,67 @@
export function formatNumberDecimal(num: number, point: number = 2): string {
return (num || 0).toLocaleString("eng", {
minimumFractionDigits: point,
maximumFractionDigits: point,
});
}
const templates = {
"quotation-labor": {
converter: (context?: { name: string[] }) => {
return context?.name.join("<br />") || "";
},
},
"quotation-payment": {
converter: (context?: {
paymentType: "Full" | "Split" | "SplitCustom" | "BillFull" | "BillSplit" | "BillSplitCustom";
amount?: number;
installments?: {
no: number;
amount: number;
}[];
}) => {
if (context?.paymentType === "Full") {
return [
"**** เงื่อนไขเพิ่มเติม",
"- เงื่อนไขการชำระเงิน แบบเต็มจำนวน",
`&nbsp; จำนวน ${formatNumberDecimal(context?.amount || 0, 2)}`,
].join("<br/>");
} else {
return [
"**** เงื่อนไขเพิ่มเติม",
`- เงื่อนไขการชำระเงิน แบบแบ่งจ่าย${context?.paymentType === "SplitCustom" ? " กำหนดเอง " : " "}${context?.installments?.length} งวด`,
...(context?.installments?.map(
(v) => `&nbsp; งวดที่ ${v.no} จำนวน ${formatNumberDecimal(v.amount, 2)}`,
) || []),
].join("<br />");
}
},
},
} as const;
type Template = typeof templates;
type TemplateName = keyof Template;
type TemplateContext = {
[key in TemplateName]?: Parameters<Template[key]["converter"]>[0];
};
export function convertTemplate(
text: string,
context?: TemplateContext,
templateUse?: TemplateName[],
) {
let ret = text;
for (const [name, template] of Object.entries(templates)) {
if (templateUse && !templateUse.includes(name as TemplateName)) continue;
ret = ret.replace(
new RegExp("\\#\\[" + name.replaceAll("-", "\\-") + "\\]", "g"),
typeof template.converter === "function"
? template.converter(context?.[name as TemplateName] as any)
: template.converter,
);
}
return ret;
}

View file

@ -62,85 +62,90 @@ export async function initThailandAreaDatabase() {
return result; return result;
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(
const meta = { async (tx) => {
createdBy: null, const meta = {
createdAt: new Date(), createdBy: null,
updatedBy: null, createdAt: new Date(),
updatedAt: new Date(), updatedBy: null,
}; updatedAt: new Date(),
};
await Promise.all( await Promise.all(
splitChunk(province, 1000, async (r) => { splitChunk(province, 1000, async (r) => {
return await tx.$kysely return await tx.$kysely
.insertInto("Province") .insertInto("Province")
.columns(["id", "name", "nameEN", "createdBy", "createdAt", "updatedBy", "updatedAt"]) .columns(["id", "name", "nameEN", "createdBy", "createdAt", "updatedBy", "updatedAt"])
.values(r.map((v) => ({ ...v, ...meta }))) .values(r.map((v) => ({ ...v, ...meta })))
.onConflict((oc) => .onConflict((oc) =>
oc.column("id").doUpdateSet({ oc.column("id").doUpdateSet({
name: (eb) => eb.ref("excluded.name"), name: (eb) => eb.ref("excluded.name"),
nameEN: (eb) => eb.ref("excluded.nameEN"), nameEN: (eb) => eb.ref("excluded.nameEN"),
updatedAt: (eb) => eb.ref("excluded.updatedAt"), updatedAt: (eb) => eb.ref("excluded.updatedAt"),
}), }),
) )
.execute(); .execute();
}), }),
); );
await Promise.all( await Promise.all(
splitChunk(district, 2000, async (r) => { splitChunk(district, 2000, async (r) => {
return await tx.$kysely return await tx.$kysely
.insertInto("District") .insertInto("District")
.columns([ .columns([
"id", "id",
"name", "name",
"nameEN", "nameEN",
"provinceId", "provinceId",
"createdBy", "createdBy",
"createdAt", "createdAt",
"updatedBy", "updatedBy",
"updatedAt", "updatedAt",
]) ])
.values(r.map((v) => ({ ...v, ...meta }))) .values(r.map((v) => ({ ...v, ...meta })))
.onConflict((oc) => .onConflict((oc) =>
oc.column("id").doUpdateSet({ oc.column("id").doUpdateSet({
name: (eb) => eb.ref("excluded.name"), name: (eb) => eb.ref("excluded.name"),
nameEN: (eb) => eb.ref("excluded.nameEN"), nameEN: (eb) => eb.ref("excluded.nameEN"),
provinceId: (eb) => eb.ref("excluded.provinceId"), provinceId: (eb) => eb.ref("excluded.provinceId"),
updatedAt: (eb) => eb.ref("excluded.updatedAt"), updatedAt: (eb) => eb.ref("excluded.updatedAt"),
}), }),
) )
.execute(); .execute();
}), }),
); );
await Promise.all( await Promise.all(
splitChunk(subDistrict, 1000, async (r) => { splitChunk(subDistrict, 1000, async (r) => {
return await tx.$kysely return await tx.$kysely
.insertInto("SubDistrict") .insertInto("SubDistrict")
.columns([ .columns([
"id", "id",
"name", "name",
"nameEN", "nameEN",
"districtId", "districtId",
"createdBy", "createdBy",
"createdAt", "createdAt",
"updatedBy", "updatedBy",
"updatedAt", "updatedAt",
]) ])
.values(r.map((v) => ({ ...v, ...meta }))) .values(r.map((v) => ({ ...v, ...meta })))
.onConflict((oc) => .onConflict((oc) =>
oc.column("id").doUpdateSet({ oc.column("id").doUpdateSet({
name: (eb) => eb.ref("excluded.name"), name: (eb) => eb.ref("excluded.name"),
nameEN: (eb) => eb.ref("excluded.nameEN"), nameEN: (eb) => eb.ref("excluded.nameEN"),
districtId: (eb) => eb.ref("excluded.districtId"), districtId: (eb) => eb.ref("excluded.districtId"),
updatedAt: (eb) => eb.ref("excluded.updatedAt"), updatedAt: (eb) => eb.ref("excluded.updatedAt"),
}), }),
) )
.execute(); .execute();
}), }),
); );
}); },
{
timeout: 15_000,
},
);
console.log("[INFO]: Sync thailand province, district and subdistrict, OK."); console.log("[INFO]: Sync thailand province, district and subdistrict, OK.");
} }
@ -170,67 +175,72 @@ export async function initEmploymentOffice() {
const list = await prisma.province.findMany(); const list = await prisma.province.findMany();
await prisma.$transaction(async (tx) => { await prisma.$transaction(
await Promise.all( async (tx) => {
list await Promise.all(
.map(async (province) => { list
if (special[province.id]) { .map(async (province) => {
await tx.employmentOffice.deleteMany({ if (special[province.id]) {
where: { provinceId: province.id, district: { none: {} } }, await tx.employmentOffice.deleteMany({
}); where: { provinceId: province.id, district: { none: {} } },
return await Promise.all( });
Object.entries(special[province.id]).map(async ([key, val]) => { return await Promise.all(
const id = province.id + "-" + key.padStart(2, "0"); Object.entries(special[province.id]).map(async ([key, val]) => {
return tx.employmentOffice.upsert({ const id = province.id + "-" + key.padStart(2, "0");
where: { id }, return tx.employmentOffice.upsert({
create: { where: { id },
id, create: {
name: nameSpecial(province.name, +key), id,
nameEN: nameSpecialEN(province.nameEN, +key), name: nameSpecial(province.name, +key),
provinceId: province.id, nameEN: nameSpecialEN(province.nameEN, +key),
district: { provinceId: province.id,
createMany: { district: {
data: val.map((districtId) => ({ districtId })), createMany: {
skipDuplicates: true, data: val.map((districtId) => ({ districtId })),
skipDuplicates: true,
},
}, },
}, },
}, update: {
update: { id,
id, name: nameSpecial(province.name, +key),
name: nameSpecial(province.name, +key), nameEN: nameSpecialEN(province.nameEN, +key),
nameEN: nameSpecialEN(province.nameEN, +key), provinceId: province.id,
provinceId: province.id, district: {
district: { deleteMany: { districtId: { notIn: val } },
deleteMany: { districtId: { notIn: val } }, createMany: {
createMany: { data: val.map((districtId) => ({ districtId })),
data: val.map((districtId) => ({ districtId })), skipDuplicates: true,
skipDuplicates: true, },
}, },
}, },
}, });
}); }),
}), );
); }
}
return tx.employmentOffice.upsert({ return tx.employmentOffice.upsert({
where: { id: province.id }, where: { id: province.id },
create: { create: {
id: province.id, id: province.id,
name: name(province.name), name: name(province.name),
nameEN: nameEN(province.nameEN), nameEN: nameEN(province.nameEN),
provinceId: province.id, provinceId: province.id,
}, },
update: { update: {
name: name(province.name), name: name(province.name),
nameEN: nameEN(province.nameEN), nameEN: nameEN(province.nameEN),
provinceId: province.id, provinceId: province.id,
}, },
}); });
}) })
.flat(), .flat(),
); );
}); },
{
timeout: 15_000,
},
);
console.log("[INFO]: Sync employment office, OK."); console.log("[INFO]: Sync employment office, OK.");
} }

323
test/branch.test.ts Normal file
View file

@ -0,0 +1,323 @@
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from "vitest";
import { PrismaClient } from "@prisma/client";
import { isDateString } from "./lib";
const prisma = new PrismaClient({
datasourceUrl: process.env.TEST_DATABASE_URL || process.env.DATABASE_URL,
});
const baseUrl = process.env.TEST_BASE_URL || "http://localhost";
const record: Record<string, any> = {
code: "CMT",
taxNo: "1052299402851",
name: "Chamomind",
nameEN: "Chamomind",
email: "contact@chamomind.com",
lineId: "@chamomind",
telephoneNo: "0988929248",
contactName: "John",
webUrl: "https://chamomind.com",
latitude: "",
longitude: "",
virtual: false,
permitNo: "1135182804792",
permitIssueDate: "2025-01-01T00:00:00.000Z",
permitExpireDate: "2030-01-01T00:00:00.000Z",
address: "11/3",
addressEN: "11/3",
soi: "1",
soiEN: "1",
moo: "2",
mooEN: "2",
street: "Straight",
streetEN: "Straight",
provinceId: "50",
districtId: "5001",
subDistrictId: "500107",
};
const recordList: Record<string, any>[] = [];
let token: string;
beforeAll(async () => {
const body = new URLSearchParams();
body.append("grant_type", "password");
body.append("client_id", "app");
body.append("username", process.env.TEST_USERNAME || "");
body.append("password", process.env.TEST_PASSWORD || "");
body.append("scope", "openid");
const res = await fetch(
process.env.KC_URL + "/realms/" + process.env.KC_REALM + "/protocol/openid-connect/token",
{
method: "POST",
body: body,
},
);
expect(res.ok).toBe(true);
await res.json().then((data) => {
token = data["access_token"];
});
});
afterAll(async () => {
if (!record["id"]) return;
await prisma.branch.deleteMany({
where: { id: { in: [record, ...recordList].map((v) => v["id"]) } },
});
await prisma.runningNo.deleteMany({
where: {
key: { in: [record, ...recordList].map((v) => `MAIN_BRANCH_${v["code"].slice(0, -5)}`) },
},
});
});
describe("branch management", () => {
it("create branch without required fields", async () => {
const requiredFields = [
"taxNo",
"name",
"nameEN",
"permitNo",
"telephoneNo",
"address",
"addressEN",
"email",
];
onTestFailed(() => console.log("Field:", requiredFields, "is required."));
for await (const field of requiredFields) {
const res = await fetch(baseUrl + "/api/v1/branch", {
method: "POST",
headers: {
["Content-Type"]: "application/json",
["Authorization"]: "Bearer " + token,
},
body: JSON.stringify({ ...record, [field]: undefined }),
});
if (res.ok) recordList.push(await res.json());
expect(res.ok).toBe(false);
expect(res.status).toBe(400);
}
});
it("create branch", async () => {
const res = await fetch(baseUrl + "/api/v1/branch", {
method: "POST",
headers: {
["Content-Type"]: "application/json",
["Authorization"]: "Bearer " + token,
},
body: JSON.stringify(record),
});
if (!res.ok) {
const text = await res.text();
try {
console.log(JSON.parse(text));
} catch (e) {
console.log(text);
}
}
expect(res.ok).toBe(true);
const data = await res.json();
record["id"] = data["id"]; // This field is auto generated
record["code"] = data["code"]; // This field is auto generated
recordList.push(data);
expect(data).toMatchObject(record);
});
it("get branch list", async () => {
const res = await fetch(baseUrl + "/api/v1/branch", {
method: "GET",
headers: {
["Authorization"]: "Bearer " + token,
},
});
if (!res.ok) {
const text = await res.text();
try {
console.log(JSON.parse(text));
} catch (e) {
console.log(text);
}
}
expect(res.ok).toBe(true);
const data = await res.json();
expect(data).toHaveProperty("result");
expect(data).toHaveProperty("total");
expect(data).toHaveProperty("page");
expect(data).toHaveProperty("pageSize");
expect(data.result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
code: expect.any(String),
virtual: expect.any(Boolean),
name: expect.any(String),
nameEN: expect.any(String),
email: expect.any(String),
taxNo: expect.any(String),
telephoneNo: expect.any(String),
latitude: expect.any(String),
longitude: expect.any(String),
contactName: expect.toBeOneOf([expect.any(String), null]),
lineId: expect.toBeOneOf([expect.any(String), null]),
webUrl: expect.toBeOneOf([expect.any(String), null]),
remark: expect.toBeOneOf([expect.any(String), null]),
selectedImage: expect.toBeOneOf([expect.any(String), null]),
isHeadOffice: expect.any(Boolean),
permitNo: expect.any(String),
permitIssueDate: expect.toSatisfy(isDateString(true)),
permitExpireDate: expect.toSatisfy(isDateString(true)),
address: expect.any(String),
addressEN: expect.any(String),
moo: expect.toBeOneOf([expect.any(String), null]),
mooEN: expect.toBeOneOf([expect.any(String), null]),
street: expect.toBeOneOf([expect.any(String), null]),
streetEN: expect.toBeOneOf([expect.any(String), null]),
provinceId: expect.any(String),
province: expect.objectContaining({
id: expect.any(String),
name: expect.any(String),
nameEN: expect.any(String),
}),
districtId: expect.any(String),
district: expect.objectContaining({
id: expect.any(String),
name: expect.any(String),
nameEN: expect.any(String),
}),
subDistrictId: expect.any(String),
subDistrict: expect.objectContaining({
id: expect.any(String),
name: expect.any(String),
nameEN: expect.any(String),
zipCode: expect.any(String),
}),
status: expect.toBeOneOf(["CREATED", "ACTIVE", "INACTIVE"]),
statusOrder: expect.toBeOneOf([1, 0]),
createdAt: expect.toSatisfy(isDateString()),
createdByUserId: expect.toBeOneOf([expect.any(String), null]),
createdBy: expect.objectContaining({
id: expect.any(String),
username: expect.any(String),
firstName: expect.any(String),
lastName: expect.any(String),
firstNameEN: expect.any(String),
lastNameEN: expect.any(String),
}),
updatedAt: expect.toSatisfy(isDateString()),
updatedByUserId: expect.toBeOneOf([expect.any(String), null]),
updatedBy: expect.objectContaining({
id: expect.any(String),
username: expect.any(String),
firstName: expect.any(String),
lastName: expect.any(String),
firstNameEN: expect.any(String),
lastNameEN: expect.any(String),
}),
_count: expect.objectContaining({
branch: expect.any(Number),
}),
}),
]),
);
});
it("get branch by id", async () => {
const res = await fetch(baseUrl + "/api/v1/branch/" + record["id"], {
method: "GET",
headers: {
["Authorization"]: "Bearer " + token,
},
});
if (!res.ok) {
const text = await res.text();
try {
console.log(JSON.parse(text));
} catch (e) {
console.log(text);
}
}
expect(res.ok).toBe(true);
const data = await res.json();
expect(data).toMatchObject(record);
});
it("update branch by id", async () => {
const res = await fetch(baseUrl + "/api/v1/branch/" + record["id"], {
method: "PUT",
headers: {
["Content-Type"]: "application/json",
["Authorization"]: "Bearer " + token,
},
body: JSON.stringify({ name: "Chamomind Intl.", nameEN: "Chamomind Intl." }),
});
record["name"] = "Chamomind Intl.";
record["nameEN"] = "Chamomind Intl.";
expect(res.ok).toBe(true);
const data = await res.json();
expect(data).toMatchObject(record);
});
it("delete branch by id", async () => {
const res = await fetch(baseUrl + "/api/v1/branch/" + record["id"], {
method: "DELETE",
headers: {
["Content-Type"]: "application/json",
["Authorization"]: "Bearer " + token,
},
});
if (!res.ok) {
const text = await res.text();
try {
console.log(JSON.parse(text));
} catch (e) {
console.log(text);
}
}
expect(res.ok).toBe(true);
const data = await res.json();
expect(data).toMatchObject(record);
});
it("get deleted branch by id", async () => {
const res = await fetch(baseUrl + "/api/v1/branch/" + record["id"], {
method: "GET",
headers: {
["Authorization"]: "Bearer " + token,
},
});
expect(res.ok).toBe(false);
});
});

10
test/lib/index.ts Normal file
View file

@ -0,0 +1,10 @@
export function isDateString(nullable: boolean = false): (val: any) => boolean {
return (value: any) => {
try {
if (value) return !!new Date(value);
return nullable;
} catch (_) {
return false;
}
};
}

5
vite.config.ts Normal file
View file

@ -0,0 +1,5 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {},
});