Compare commits

...

147 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
47 changed files with 3771 additions and 683 deletions

View file

@ -1,34 +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 fontconfig && rm -rf /var/lib/apt/lists/*
RUN fc-cache -f -v
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",
@ -27,22 +28,26 @@
"@types/multer": "^1.4.12", "@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",
"@types/html-to-text": "^9.0.4",
"canvas": "^3.1.0", "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",
@ -50,6 +55,7 @@
"exceljs": "^4.4.0", "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", "jsbarcode": "^3.11.6",
"json-2-csv": "^5.5.8", "json-2-csv": "^5.5.8",
"kysely": "^0.27.5", "kysely": "^0.27.5",
@ -57,6 +63,7 @@
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.2", "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",

1312
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

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

@ -370,7 +370,7 @@ model UserImportNationality {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String userId String
} }
@ -398,15 +398,25 @@ model User {
street String? street String?
streetEN String? streetEN String?
addressForeign Boolean @default(false)
provinceText String?
provinceTextEN String?
province Province? @relation(fields: [provinceId], references: [id], onDelete: SetNull) province Province? @relation(fields: [provinceId], references: [id], onDelete: SetNull)
provinceId String? provinceId String?
districtText String?
districtTextEN String?
district District? @relation(fields: [districtId], references: [id], onDelete: SetNull) district District? @relation(fields: [districtId], references: [id], onDelete: SetNull)
districtId String? districtId String?
subDistrictText String?
subDistrictTextEN String?
subDistrict SubDistrict? @relation(fields: [subDistrictId], references: [id], onDelete: SetNull) subDistrict SubDistrict? @relation(fields: [subDistrictId], references: [id], onDelete: SetNull)
subDistrictId String? subDistrictId String?
zipCodeText String?
email String email String
telephoneNo String telephoneNo String
@ -501,6 +511,8 @@ model User {
creditNoteCreated CreditNote[] @relation("CreditNoteCreatedByUser") creditNoteCreated CreditNote[] @relation("CreditNoteCreatedByUser")
institutionCreated Institution[] @relation("InstitutionCreatedByUser") institutionCreated Institution[] @relation("InstitutionCreatedByUser")
institutionUpdated Institution[] @relation("InstitutionUpdatedByUser") institutionUpdated Institution[] @relation("InstitutionUpdatedByUser")
businessTypeCreated BusinessType[] @relation("BusinessTypeCreatedByUser")
businessTypeUpdated BusinessType[] @relation("BusinessTypeUpdatedByUser")
requestWorkStepStatus RequestWorkStepStatus[] requestWorkStepStatus RequestWorkStepStatus[]
userTask UserTask[] userTask UserTask[]
@ -511,6 +523,7 @@ model User {
contactName String? contactName String?
contactTel String? contactTel String?
quotation Quotation[]
} }
model UserResponsibleArea { model UserResponsibleArea {
@ -552,7 +565,6 @@ 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
@ -614,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
@ -773,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())
@ -896,6 +924,7 @@ model EmployeeVisa {
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?
@ -1214,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[]
@ -1386,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 {
@ -1494,6 +1529,9 @@ model Payment {
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(name: "PaymentCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull) createdBy User? @relation(name: "PaymentCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
@ -1580,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

View file

@ -34,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,
@ -287,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;
@ -295,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;
}; };
@ -330,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:
@ -345,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(" ");
} }
@ -365,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

@ -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

@ -47,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 = {
@ -147,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")
@ -326,7 +330,7 @@ export class BranchController extends Controller {
district: true, district: true,
subDistrict: true, subDistrict: true,
}, },
orderBy: { code: "asc" }, orderBy: [{ statusOrder: "asc" }, { code: "asc" }],
} }
: false, : false,
bank: true, bank: true,
@ -370,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,
@ -383,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

@ -20,10 +20,19 @@ import { RequestWithUser } from "../interfaces/user";
import { branchRelationPermInclude, createPermCheck } from "../services/permission"; import { branchRelationPermInclude, createPermCheck } from "../services/permission";
import { queryOrNot, whereDateQuery } 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;
} }

View file

@ -61,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;
} }
@ -104,6 +111,7 @@ type UserCreate = {
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;
@ -115,9 +123,16 @@ 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;
@ -144,9 +159,9 @@ type UserUpdate = {
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;
@ -166,6 +181,7 @@ type UserUpdate = {
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;
@ -179,9 +195,16 @@ 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[];

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

@ -47,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);
@ -84,7 +87,6 @@ export type CustomerBranchCreate = {
authorizedCapital?: string; authorizedCapital?: string;
authorizedName?: string; authorizedName?: string;
authorizedNameEN?: string; authorizedNameEN?: string;
customerName?: string;
telephoneNo: string; telephoneNo: string;
@ -108,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;
@ -142,7 +144,6 @@ export type CustomerBranchUpdate = {
authorizedCapital?: string; authorizedCapital?: string;
authorizedName?: string; authorizedName?: string;
authorizedNameEN?: string; authorizedNameEN?: string;
customerName?: string;
telephoneNo: string; telephoneNo: string;
@ -166,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;
@ -201,7 +202,6 @@ export class CustomerBranchController extends Controller {
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.CustomerBranchWhereInput[]>(query, [ OR: queryOrNot<Prisma.CustomerBranchWhereInput[]>(query, [
{ customerName: { contains: query, mode: "insensitive" } },
{ registerName: { contains: query, mode: "insensitive" } }, { registerName: { contains: query, mode: "insensitive" } },
{ registerNameEN: { contains: query, mode: "insensitive" } }, { registerNameEN: { contains: query, mode: "insensitive" } },
{ email: { contains: query, mode: "insensitive" } }, { email: { contains: query, mode: "insensitive" } },
@ -238,6 +238,11 @@ export class CustomerBranchController extends Controller {
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,
@ -246,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,
@ -261,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,
@ -268,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 },
}); });
@ -350,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" },
}, },
@ -378,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) => {
@ -421,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,
@ -432,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 } },
}, },
@ -462,6 +489,7 @@ export class CustomerBranchController extends Controller {
}, },
}, },
}, },
businessType: true,
}, },
}); });
@ -506,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 },
@ -516,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,
@ -525,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 } },
}, },
}); });
@ -543,6 +581,7 @@ export class CustomerBranchController extends Controller {
}, },
}, },
}, },
businessType: true,
}, },
}); });
@ -595,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

@ -37,20 +37,24 @@ import {
} from "../utils/minio"; } from "../utils/minio";
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
import { connectOrNot, queryOrNot, whereDateQuery } 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;
@ -167,11 +170,14 @@ export class CustomerController extends Controller {
@Query() activeBranchOnly?: boolean, @Query() activeBranchOnly?: boolean,
@Query() startDate?: Date, @Query() startDate?: Date,
@Query() endDate?: 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, mode: "insensitive" } } } }, { branch: { some: { namePrefix: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { customerName: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { registerName: { contains: query, mode: "insensitive" } } } }, { branch: { some: { registerName: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { registerNameEN: { contains: query, mode: "insensitive" } } } }, { branch: { some: { registerNameEN: { contains: query, mode: "insensitive" } } } },
{ branch: { some: { firstName: { contains: query, mode: "insensitive" } } } }, { branch: { some: { firstName: { contains: query, mode: "insensitive" } } } },
@ -190,6 +196,35 @@ 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), ...whereDateQuery(startDate, endDate),
} satisfies Prisma.CustomerWhereInput; } satisfies Prisma.CustomerWhereInput;
@ -200,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" }],
} }
: { : {
@ -212,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,
@ -241,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,
@ -312,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,
@ -323,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),
@ -409,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,
@ -447,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,
@ -542,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

@ -42,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.");
@ -51,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 = {
@ -108,7 +115,7 @@ type EmployeeUpdate = {
nrcNo?: string | null; nrcNo?: string | null;
dateOfBirth?: Date; dateOfBirth?: Date | null;
gender?: string; gender?: string;
nationality?: string; nationality?: string;
otherNationality?: string | null; otherNationality?: string | null;
@ -144,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) },
},
},
},
}); });
} }
@ -234,7 +250,6 @@ export class EmployeeController extends Controller {
endDate, endDate,
); );
} }
@Post("list") @Post("list")
@Security("keycloak") @Security("keycloak")
async listByCriteria( async listByCriteria(
@ -656,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")
@ -912,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,7 +47,7 @@ type EmployeePassportPayload = {
workerStatus: string; workerStatus: string;
nationality: string; nationality: string;
otherNationality?: 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

@ -44,14 +44,30 @@ type WorkflowPayload = {
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,
@ -118,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: {
@ -150,6 +167,7 @@ export class FlowTemplateController extends Controller {
} }
@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: [
@ -230,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,
@ -315,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

@ -95,6 +95,17 @@ type InstitutionUpdatePayload = {
}[]; }[];
}; };
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 {
@ -185,7 +196,7 @@ export class InstitutionController extends Controller {
} }
@Post() @Post()
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
@OperationId("createInstitution") @OperationId("createInstitution")
async createInstitution( async createInstitution(
@Body() @Body()
@ -229,7 +240,7 @@ export class InstitutionController extends Controller {
} }
@Put("{institutionId}") @Put("{institutionId}")
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
@OperationId("updateInstitution") @OperationId("updateInstitution")
async updateInstitution( async updateInstitution(
@Path() institutionId: string, @Path() institutionId: string,
@ -278,7 +289,7 @@ export class InstitutionController extends Controller {
} }
@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) => {
@ -350,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,
@ -364,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,
@ -394,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,
@ -405,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,
@ -436,7 +447,7 @@ export class InstitutionFileController extends Controller {
} }
@Put("bank-qr/{bankId}") @Put("bank-qr/{bankId}")
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
async putBankImage( async putBankImage(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Path() institutionId: string, @Path() institutionId: string,
@ -450,7 +461,7 @@ export class InstitutionFileController extends Controller {
} }
@Delete("bank-qr/{bankId}") @Delete("bank-qr/{bankId}")
@Security("keycloak") @Security("keycloak", MANAGE_ROLES)
async delBankImage( async delBankImage(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Path() institutionId: string, @Path() institutionId: string,

View file

@ -29,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")
@ -108,7 +117,6 @@ export class InvoiceController extends Controller {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query, mode: "insensitive" } },
{ registerName: { contains: query, mode: "insensitive" } }, { registerName: { contains: query, mode: "insensitive" } },
{ registerNameEN: { contains: query, mode: "insensitive" } }, { registerNameEN: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } },
@ -184,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({
@ -229,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

@ -30,19 +30,23 @@ import { deleteFile, deleteFolder, fileLocation, getFile, listFile, setFile } fr
import { isUsedError, notFoundError, relationError } from "../utils/error"; import { isUsedError, notFoundError, relationError } from "../utils/error";
import { queryOrNot, whereDateQuery } from "../utils/relation"; import { queryOrNot, whereDateQuery } from "../utils/relation";
import spreadsheet from "../utils/spreadsheet"; 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);
@ -74,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;
@ -297,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 })) },
@ -377,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,
@ -439,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({
@ -640,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

@ -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);
@ -157,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,
); );
@ -181,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

@ -42,14 +42,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 permissionCondCompany = createPermCondition((_) => true); const permissionCondCompany = createPermCondition((_) => true);

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,18 @@ 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, @Request() req: RequestWithUser,
) { ) {
const record = await prisma.payment.findUnique({ const record = await prisma.payment.findUnique({
@ -135,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();
@ -240,7 +268,7 @@ export class QuotationPayment extends Controller {
}, },
}); });
if (quotation.quotationStatus === "PaymentPending") { if (quotation.quotationStatus === "PaymentInProcess") {
await prisma.notification.create({ await prisma.notification.create({
data: { data: {
title: "รายการคำขอใหม่ / New Request", title: "รายการคำขอใหม่ / New Request",

View file

@ -84,6 +84,8 @@ type QuotationCreate = {
installmentNo?: number; installmentNo?: number;
workerIndex?: number[]; workerIndex?: number[];
}[]; }[];
sellerId?: string;
}; };
type QuotationUpdate = { type QuotationUpdate = {
@ -142,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;
@ -150,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);
@ -210,6 +215,7 @@ export class QuotationController extends Controller {
@Query() query = "", @Query() query = "",
@Query() startDate?: Date, @Query() startDate?: Date,
@Query() endDate?: Date, @Query() endDate?: Date,
@Query() sellerId?: string,
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.QuotationWhereInput[]>(query, [ OR: queryOrNot<Prisma.QuotationWhereInput[]>(query, [
@ -219,7 +225,6 @@ export class QuotationController extends Controller {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query, mode: "insensitive" } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query, mode: "insensitive" } }, { lastName: { contains: query, mode: "insensitive" } },
@ -258,6 +263,7 @@ export class QuotationController extends Controller {
} }
: undefined, : undefined,
...whereDateQuery(startDate, endDate), ...whereDateQuery(startDate, endDate),
sellerId: sellerId,
} satisfies Prisma.QuotationWhereInput; } satisfies Prisma.QuotationWhereInput;
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
@ -415,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"),
@ -521,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,
@ -551,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),
); );
@ -658,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" },
],
},
}, },
}); });
@ -666,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,
@ -802,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 {
@ -832,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),
); );
@ -856,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 (
@ -889,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: {

View file

@ -95,7 +95,6 @@ export class RequestDataController extends Controller {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query, mode: "insensitive" } },
{ registerName: { contains: query, mode: "insensitive" } }, { registerName: { contains: query, mode: "insensitive" } },
{ registerNameEN: { contains: query, mode: "insensitive" } }, { registerNameEN: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } },
@ -294,14 +293,17 @@ export class RequestDataController extends Controller {
async updateRequestData( async updateRequestData(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Body() @Body()
boby: { body: {
defaultMessengerId: string; defaultMessengerId: string;
requestDataId: string[]; requestDataId: string[];
}, },
) { ) {
const record = await prisma.requestData.updateManyAndReturn({ if (body.requestDataId.length === 0) return;
return await prisma.$transaction(async (tx) => {
const record = await tx.requestData.updateManyAndReturn({
where: { where: {
id: { in: boby.requestDataId }, id: { in: body.requestDataId },
quotation: { quotation: {
registeredBranch: { registeredBranch: {
OR: permissionCond(req.user), OR: permissionCond(req.user),
@ -309,19 +311,44 @@ export class RequestDataController extends Controller {
}, },
}, },
data: { data: {
defaultMessengerId: boby.defaultMessengerId, defaultMessengerId: body.defaultMessengerId,
}, },
}); });
if (record.length <= 0) throw notFoundError("Request Data"); 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]; 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(
@ -396,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");
@ -438,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) =>
tx.notification.create({
data: {
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
detail: "รหัส / code : " + v.code + " Canceled", detail: "รหัส / code : " + v.code + " Canceled",
receiverId: v.createdByUserId, receiverId: v.createdByUserId,
})), registeredBranchId: v.registeredBranchId,
}); groupReceiver: { create: { name: "document_checker" } },
},
}), }),
tx.taskOrder.updateMany({ ),
);
}),
tx.taskOrder
.updateManyAndReturn({
where: { where: {
taskList: { taskList: {
every: { taskStatus: TaskStatus.Canceled }, 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),
});
}); });
} }
@ -586,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) =>
tx.notification.create({
data: {
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
detail: "รหัส / code : " + v.code + " Canceled", detail: "รหัส / code : " + v.code + " Canceled",
receiverId: v.createdByUserId, receiverId: v.createdByUserId,
})), registeredBranchId: v.registeredBranchId,
}); groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
}), }),
tx.taskOrder.updateMany({ tx.taskOrder.updateMany({
where: { where: {
@ -675,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) =>
tx.notification.create({
data: {
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
detail: "รหัส / code : " + v.code + " Completed", detail: "รหัส / code : " + v.code + " Completed",
receiverId: v.createdByUserId, 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);
@ -977,7 +1155,7 @@ export class RequestListController extends Controller {
}); });
if (record.responsibleUserId === null) { if (record.responsibleUserId === null) {
await prisma.requestWorkStepStatus.update({ await tx.requestWorkStepStatus.update({
where: { where: {
step_requestWorkId: { step_requestWorkId: {
step: step, step: step,
@ -1039,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) =>
tx.notification.create({
data: {
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
detail: "รหัส / code : " + v.code + " Canceled", detail: "รหัส / code : " + v.code + " Canceled",
receiverId: v.createdByUserId, receiverId: v.createdByUserId,
})), registeredBranchId: v.registeredBranchId,
}); groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
}), }),
tx.taskOrder.updateMany({ tx.taskOrder.updateMany({
where: { where: {
@ -1153,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) =>
tx.notification.create({
data: {
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
detail: "รหัส / code : " + v.code + " Completed", detail: "รหัส / code : " + v.code + " Completed",
receiverId: v.createdByUserId, 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

@ -44,11 +44,21 @@ import {
} from "../utils/minio"; } from "../utils/minio";
import { queryOrNot, whereDateQuery } 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 }),
{ {
@ -252,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: {
@ -301,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({
@ -325,7 +344,8 @@ export class TaskController extends Controller {
where: { OR: taskList }, where: { OR: taskList },
}); });
return await tx.taskOrder.create({ return await tx.taskOrder
.create({
include: { include: {
taskList: { taskList: {
include: { include: {
@ -388,6 +408,17 @@ export class TaskController extends Controller {
taskList: { create: taskList }, taskList: { create: taskList },
taskProduct: { create: taskProduct }, 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;
}); });
}); });
} }
@ -531,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" } },
}, },
}); });
} }
@ -658,6 +691,7 @@ export class TaskActionController extends Controller {
title: "ใบรายการคำขอที่จัดการเกิดปัญหา / Task Failed", title: "ใบรายการคำขอที่จัดการเกิดปัญหา / Task Failed",
detail: `ใบรายการคำขอรหัส ${taskCode}: ${taskName} รหัสสินค้า ${productCode}: ${productName} ของลูกจ้าง ${employeeName} เกิดข้อผิดพลาด`, detail: `ใบรายการคำขอรหัส ${taskCode}: ${taskName} รหัสสินค้า ${productCode}: ${productName} ของลูกจ้าง ${employeeName} เกิดข้อผิดพลาด`,
groupReceiver: { create: { name: "document_checker" } }, groupReceiver: { create: { name: "document_checker" } },
receiverId: record.requestWorkStep.requestWork.request.quotation.createdByUserId,
registeredBranchId: record.taskOrder.registeredBranchId, registeredBranchId: record.taskOrder.registeredBranchId,
}, },
}); });
@ -724,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" } },
}, },
}), }),
]); ]);
@ -756,7 +792,8 @@ export class TaskActionController extends Controller {
const code = `RI${year}${month}${last.value.toString().padStart(6, "0")}`; const code = `RI${year}${month}${last.value.toString().padStart(6, "0")}`;
await Promise.all([ await Promise.all([
tx.taskOrder.update({ tx.taskOrder
.update({
where: { id: taskOrderId }, where: { id: taskOrderId },
data: { data: {
urgent: false, urgent: false,
@ -771,6 +808,17 @@ export class TaskActionController extends Controller {
}, },
}, },
}, },
})
.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: {
@ -875,9 +923,33 @@ export class TaskActionController extends Controller {
if (completeCheck) completed.push(item.id); if (completeCheck) completed.push(item.id);
}); });
await tx.requestData.updateMany({ await tx.requestData
.updateManyAndReturn({
where: { id: { in: completed } }, where: { id: { in: completed } },
include: {
quotation: {
select: {
registeredBranchId: true,
createdByUserId: true,
},
},
},
data: { requestDataStatus: RequestDataStatus.Completed }, 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({
@ -918,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) =>
tx.notification.create({
data: {
title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated",
detail: "รหัส / code : " + v.code + " Completed", detail: "รหัส / code : " + v.code + " Completed",
receiverId: v.createdByUserId, receiverId: v.createdByUserId,
})), registeredBranchId: v.registeredBranchId,
}); groupReceiver: { create: { name: "document_checker" } },
},
}),
),
);
const token = await this.#getLineToken(); const token = await this.#getLineToken();
@ -1163,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" } },
}, },
{ });
await tx.notification.create({
data: {
title: "มีการรับงาน / Task Accepted", title: "มีการรับงาน / Task Accepted",
detail: "รหัสใบสั่งงาน / Order : " + v.code, detail: "รหัสใบสั่งงาน / Order : " + v.code,
receiverId: v.createdByUserId, 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";
@ -42,22 +43,21 @@ 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) },
}, },
}, },
}, },
@ -148,7 +156,6 @@ export class CreditNoteController extends Controller {
@Query() creditNoteStatus?: CreditNoteStatus, @Query() creditNoteStatus?: CreditNoteStatus,
@Query() startDate?: Date, @Query() startDate?: Date,
@Query() endDate?: Date, @Query() endDate?: Date,
@Body() body?: {},
) { ) {
const where = { const where = {
OR: queryOrNot<Prisma.CreditNoteWhereInput[]>(query, [ OR: queryOrNot<Prisma.CreditNoteWhereInput[]>(query, [
@ -165,7 +172,6 @@ export class CreditNoteController extends Controller {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query, mode: "insensitive" } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query, mode: "insensitive" } }, { lastName: { contains: query, mode: "insensitive" } },
@ -200,7 +206,7 @@ export class CreditNoteController extends Controller {
request: { request: {
quotationId, quotationId,
quotation: { quotation: {
registeredBranch: { OR: permissionCondCompany(req.user) }, registeredBranch: { OR: permissionCond(req.user) },
}, },
}, },
}, },
@ -211,6 +217,8 @@ export class CreditNoteController extends Controller {
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: {
@ -243,7 +251,7 @@ export class CreditNoteController extends Controller {
some: { some: {
request: { request: {
quotation: { quotation: {
registeredBranch: { OR: permissionCondCompany(req.user) }, registeredBranch: { OR: permissionCond(req.user) },
}, },
}, },
}, },
@ -341,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;
@ -369,14 +376,23 @@ export class CreditNoteController extends Controller {
update: { value: { increment: 1 } }, update: { value: { increment: 1 } },
}); });
return await prisma.creditNote.create({ return await prisma.creditNote
.create({
include: { include: {
requestWork: { requestWork: {
include: { include: {
request: true, request: true,
}, },
}, },
quotation: true, quotation: {
include: {
customerBranch: {
include: {
customer: { include: { branch: { where: { userId: { not: null } } } } },
},
},
},
},
}, },
data: { data: {
reason: body.reason, reason: body.reason,
@ -395,6 +411,55 @@ export class CreditNoteController extends Controller {
}, },
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 },
@ -402,7 +467,7 @@ export class CreditNoteController extends Controller {
} }
@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,
@ -477,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;
@ -576,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) {
@ -594,7 +666,8 @@ 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
.update({
where: { id: creditNoteId }, where: { id: creditNoteId },
include: { include: {
requestWork: { requestWork: {
@ -602,7 +675,15 @@ export class CreditNoteActionController extends Controller {
request: true, request: true,
}, },
}, },
quotation: true, quotation: {
include: {
customerBranch: {
include: {
customer: { include: { branch: { where: { userId: { not: null } } } } },
},
},
},
},
}, },
data: { data: {
creditNoteStatus: creditNoteStatus:
@ -610,6 +691,55 @@ export class CreditNoteActionController extends Controller {
paybackStatus: body.paybackStatus, paybackStatus: body.paybackStatus,
paybackDate: body.paybackStatus === PaybackStatus.Done ? new Date() : undefined, 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

@ -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;
@ -213,7 +211,6 @@ export class DebitNoteController extends Controller {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query, mode: "insensitive" } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query, mode: "insensitive" } }, { lastName: { contains: query, mode: "insensitive" } },
@ -433,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 {
@ -461,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),
); );
@ -581,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,
@ -605,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 = {
@ -676,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 {
@ -704,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

@ -189,7 +189,6 @@ export class LineController extends Controller {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query, mode: "insensitive" } },
{ registerName: { contains: query, mode: "insensitive" } }, { registerName: { contains: query, mode: "insensitive" } },
{ registerNameEN: { contains: query, mode: "insensitive" } }, { registerNameEN: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } },
@ -614,17 +613,14 @@ export class LineController extends Controller {
@Query() endDate?: Date, @Query() endDate?: Date,
) { ) {
const where = { const where = {
OR: OR: queryOrNot<Prisma.QuotationWhereInput[]>(query, [
query || pendingOnly
? [
...(queryOrNot<Prisma.QuotationWhereInput[]>(query, [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ workName: { contains: query, mode: "insensitive" } }, { workName: { contains: query, mode: "insensitive" } },
{ {
customerBranch: { customerBranch: {
OR: [ OR: [
{ code: { contains: query, mode: "insensitive" } }, { code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query, mode: "insensitive" } }, { registerName: { contains: query, mode: "insensitive" } },
{ firstName: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } },
{ firstNameEN: { contains: query, mode: "insensitive" } }, { firstNameEN: { contains: query, mode: "insensitive" } },
{ lastName: { contains: query, mode: "insensitive" } }, { lastName: { contains: query, mode: "insensitive" } },
@ -632,21 +628,7 @@ export class LineController extends Controller {
], ],
}, },
}, },
]) || []), ]),
...(queryOrNot<Prisma.QuotationWhereInput[]>(!!pendingOnly, [
{
requestData: {
some: {
requestDataStatus: "Pending",
},
},
},
{
requestData: { none: {} },
},
]) || []),
]
: undefined,
isDebitNote: false, isDebitNote: false,
code, code,
payCondition, payCondition,
@ -668,6 +650,22 @@ export class LineController extends Controller {
}, },
} }
: undefined, : undefined,
AND: pendingOnly
? {
OR: [
{
requestData: {
some: {
requestDataStatus: "Pending",
},
},
},
{
requestData: { none: {} },
},
],
}
: undefined,
...whereDateQuery(startDate, endDate), ...whereDateQuery(startDate, endDate),
} satisfies Prisma.QuotationWhereInput; } satisfies Prisma.QuotationWhereInput;

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

@ -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

@ -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,7 +62,8 @@ export async function initThailandAreaDatabase() {
return result; return result;
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(
async (tx) => {
const meta = { const meta = {
createdBy: null, createdBy: null,
createdAt: new Date(), createdAt: new Date(),
@ -140,7 +141,11 @@ export async function initThailandAreaDatabase() {
.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,7 +175,8 @@ export async function initEmploymentOffice() {
const list = await prisma.province.findMany(); const list = await prisma.province.findMany();
await prisma.$transaction(async (tx) => { await prisma.$transaction(
async (tx) => {
await Promise.all( await Promise.all(
list list
.map(async (province) => { .map(async (province) => {
@ -230,7 +236,11 @@ export async function initEmploymentOffice() {
}) })
.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: {},
});