From 9fd2064a711e588bc6f1a22e5f40c72a568dfc8f Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Mon, 20 Jan 2025 10:18:08 +0700 Subject: [PATCH 1/4] chore: deps --- package.json | 6 ++-- pnpm-lock.yaml | 94 +++++++++++++++++++++++++------------------------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index 998ef61..8d647b9 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@types/node": "^20.17.10", "nodemon": "^3.1.9", "prettier": "^3.4.2", - "prisma": "^6.0.1", + "prisma": "^6.2.1", "prisma-kysely": "^1.8.0", "ts-node": "^10.9.2", "typescript": "^5.7.2" @@ -32,8 +32,8 @@ "dependencies": { "@elastic/elasticsearch": "^8.17.0", "@fast-csv/parse": "^5.0.2", - "@prisma/client": "^6.0.1", - "@scalar/express-api-reference": "^0.4.172", + "@prisma/client": "^6.2.1", + "@scalar/express-api-reference": "^0.4.173", "@tsoa/runtime": "^6.6.0", "@types/morgan": "^1.9.9", "cors": "^2.8.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6a6cc9..95f2fa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,11 +15,11 @@ importers: specifier: ^5.0.2 version: 5.0.2 '@prisma/client': - specifier: ^6.0.1 - version: 6.0.1(prisma@6.0.1) + specifier: ^6.2.1 + version: 6.2.1(prisma@6.2.1) '@scalar/express-api-reference': - specifier: ^0.4.172 - version: 0.4.172 + specifier: ^0.4.173 + version: 0.4.173 '@tsoa/runtime': specifier: ^6.6.0 version: 6.6.0 @@ -52,7 +52,7 @@ importers: version: 1.10.0 prisma-extension-kysely: specifier: ^3.0.0 - version: 3.0.0(@prisma/client@6.0.1(prisma@6.0.1)) + version: 3.0.0(@prisma/client@6.2.1(prisma@6.2.1)) promise.any: specifier: ^2.0.6 version: 2.0.6 @@ -82,8 +82,8 @@ importers: specifier: ^3.4.2 version: 3.4.2 prisma: - specifier: ^6.0.1 - version: 6.0.1 + specifier: ^6.2.1 + version: 6.2.1 prisma-kysely: specifier: ^1.8.0 version: 1.8.0 @@ -309,8 +309,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@prisma/client@6.0.1': - resolution: {integrity: sha512-60w7kL6bUxz7M6Gs/V+OWMhwy94FshpngVmOY05TmGD0Lhk+Ac0ZgtjlL6Wll9TD4G03t4Sq1wZekNVy+Xdlbg==} + '@prisma/client@6.2.1': + resolution: {integrity: sha512-msKY2iRLISN8t5X0Tj7hU0UWet1u0KuxSPHWuf3IRkB4J95mCvGpyQBfQ6ufcmvKNOMQSq90O2iUmJEN2e5fiA==} engines: {node: '>=18.18'} peerDependencies: prisma: '*' @@ -321,23 +321,23 @@ packages: '@prisma/debug@5.3.1': resolution: {integrity: sha512-eYrxqslEKf+wpMFIIHgbcNYuZBXUdiJLA85Or3TwOhgPIN1ZoXT9CwJph3ynW8H1Xg0LkdYLwVmuULCwiMoU5A==} - '@prisma/debug@6.0.1': - resolution: {integrity: sha512-jQylgSOf7ibTVxqBacnAlVGvek6fQxJIYCQOeX2KexsfypNzXjJQSS2o5s+Mjj2Np93iSOQUaw6TvPj8syhG4w==} + '@prisma/debug@6.2.1': + resolution: {integrity: sha512-0KItvt39CmQxWkEw6oW+RQMD6RZ43SJWgEUnzxN8VC9ixMysa7MzZCZf22LCK5DSooiLNf8vM3LHZm/I/Ni7bQ==} - '@prisma/engines-version@5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e': - resolution: {integrity: sha512-JmIds0Q2/vsOmnuTJYxY4LE+sajqjYKhLtdOT6y4imojqv5d/aeVEfbBGC74t8Be1uSp0OP8lxIj2OqoKbLsfQ==} + '@prisma/engines-version@6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69': + resolution: {integrity: sha512-7tw1qs/9GWSX6qbZs4He09TOTg1ff3gYsB3ubaVNN0Pp1zLm9NC5C5MZShtkz7TyQjx7blhpknB7HwEhlG+PrQ==} '@prisma/engines@5.3.1': resolution: {integrity: sha512-6QkILNyfeeN67BNEPEtkgh3Xo2tm6D7V+UhrkBbRHqKw9CTaz/vvTP/ROwYSP/3JT2MtIutZm/EnhxUiuOPVDA==} - '@prisma/engines@6.0.1': - resolution: {integrity: sha512-4hxzI+YQIR2uuDyVsDooFZGu5AtixbvM2psp+iayDZ4hRrAHo/YwgA17N23UWq7G6gRu18NvuNMb48qjP3DPQw==} + '@prisma/engines@6.2.1': + resolution: {integrity: sha512-lTBNLJBCxVT9iP5I7Mn6GlwqAxTpS5qMERrhebkUhtXpGVkBNd/jHnNJBZQW4kGDCKaQg/r2vlJYkzOHnAb7ZQ==} '@prisma/fetch-engine@5.3.1': resolution: {integrity: sha512-w1yk1YiK8N82Pobdq58b85l6e8akyrkxuzwV9DoiUTRf3gpsuhJJesHc4Yi0WzUC9/3znizl1UfCsI6dhkj3Vw==} - '@prisma/fetch-engine@6.0.1': - resolution: {integrity: sha512-T36bWFVGeGYYSyYOj9d+O9G3sBC+pAyMC+jc45iSL63/Haq1GrYjQPgPMxrEj9m739taXrupoysRedQ+VyvM/Q==} + '@prisma/fetch-engine@6.2.1': + resolution: {integrity: sha512-OO7O9d6Mrx2F9i+Gu1LW+DGXXyUFkP7OE5aj9iBfA/2jjDXEJjqa9X0ZmM9NZNo8Uo7ql6zKm6yjDcbAcRrw1A==} '@prisma/generator-helper@5.3.1': resolution: {integrity: sha512-zrYS0iHLgPlOJjYnd5KvVMMvSS+ktOL39EwooS5EnyvfzwfzxlKCeOUgxTfiKYs0WUWqzEvyNAYtramYgSknsQ==} @@ -345,8 +345,8 @@ packages: '@prisma/get-platform@5.3.1': resolution: {integrity: sha512-3IiZY2BUjKnAuZ0569zppZE6/rZbVAM09//c2nvPbbkGG9MqrirA8fbhhF7tfVmhyVfdmVCHnf/ujWPHJ8B46Q==} - '@prisma/get-platform@6.0.1': - resolution: {integrity: sha512-zspC9vlxAqx4E6epMPMLLBMED2VD8axDe8sPnquZ8GOsn6tiacWK0oxrGK4UAHYzYUVuMVUApJbdXB2dFpLhvg==} + '@prisma/get-platform@6.2.1': + resolution: {integrity: sha512-zp53yvroPl5m5/gXYLz7tGCNG33bhG+JYCm74ohxOq1pPnrL47VQYFfF3RbTZ7TzGWCrR3EtoiYMywUBw7UK6Q==} '@prisma/internals@5.3.1': resolution: {integrity: sha512-zkW73hPHHNrMD21PeYgCTBfMu71vzJf+WtfydtJbS0JVJKyLfOel0iWSQg7wjNeQfccKp+NdHJ/5rTJ4NEUzgA==} @@ -354,16 +354,16 @@ packages: '@prisma/prisma-schema-wasm@5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59': resolution: {integrity: sha512-+zUI7NQDXfcNnU8HgrAj4jRMv8yRfITLzcfv0Urf0adKimM+hkkVG4rX38i9zWMlxekkEBw7NLFx3Gxxy8d3iQ==} - '@scalar/express-api-reference@0.4.172': - resolution: {integrity: sha512-FoJU1ajSwRPFxQehO5kh86xIVZ2QIQxuDra32w5RYy9UCGaMYQcmXxhUbmKBM+b5GA6VwiLqObMir8ICR3B5Yw==} + '@scalar/express-api-reference@0.4.173': + resolution: {integrity: sha512-C/s+rdImUb7gLbYRH6ICZjVPV8SHHMwJQ/Fiu6fVGomPldi0fpTDMaXH+9jRIlTm3LR6ACl/OlH7MXcPPRaFSA==} engines: {node: '>=18'} '@scalar/openapi-types@0.1.5': resolution: {integrity: sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw==} engines: {node: '>=18'} - '@scalar/types@0.0.24': - resolution: {integrity: sha512-ahMb4WYFx9OP32r7j8KhyMzg5zU78gnKxhDNapuC8WMzlSyyOsX5LQLx8sUQJfw3ZvUHp+F2zv5GJSHD+hdIiA==} + '@scalar/types@0.0.25': + resolution: {integrity: sha512-sGnOFnfiSn4o23rklU/jrg81hO+630bsFIdHg8MZ/w2Nc6IoUwARA2hbe4d4Fg+D0KBu40Tan/L+WAYDXkTJQg==} engines: {node: '>=18'} '@swc/helpers@0.5.15': @@ -1954,8 +1954,8 @@ packages: resolution: {integrity: sha512-VpNpolZ8RXRgfU+j4R+fPZmX8EE95w3vJ2tt7+FwuiQc0leNTfLK5QLf3KbbPDes2rfjh3g20AjDxefQIo5GIA==} hasBin: true - prisma@6.0.1: - resolution: {integrity: sha512-CaMNFHkf+DDq8zq3X/JJsQ4Koy7dyWwwtOKibkT/Am9j/tDxcfbg7+lB1Dzhx18G/+RQCMgjPYB61bhRqteNBQ==} + prisma@6.2.1: + resolution: {integrity: sha512-hhyM0H13pQleQ+br4CkzGizS5I0oInoeTw3JfLw1BRZduBSQxPILlJLwi+46wZzj9Je7ndyQEMGw/n5cN2fknA==} engines: {node: '>=18.18'} hasBin: true @@ -2938,9 +2938,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@prisma/client@6.0.1(prisma@6.0.1)': + '@prisma/client@6.2.1(prisma@6.2.1)': optionalDependencies: - prisma: 6.0.1 + prisma: 6.2.1 '@prisma/debug@5.3.1': dependencies: @@ -2950,18 +2950,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@prisma/debug@6.0.1': {} + '@prisma/debug@6.2.1': {} - '@prisma/engines-version@5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e': {} + '@prisma/engines-version@6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69': {} '@prisma/engines@5.3.1': {} - '@prisma/engines@6.0.1': + '@prisma/engines@6.2.1': dependencies: - '@prisma/debug': 6.0.1 - '@prisma/engines-version': 5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e - '@prisma/fetch-engine': 6.0.1 - '@prisma/get-platform': 6.0.1 + '@prisma/debug': 6.2.1 + '@prisma/engines-version': 6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69 + '@prisma/fetch-engine': 6.2.1 + '@prisma/get-platform': 6.2.1 '@prisma/fetch-engine@5.3.1': dependencies: @@ -2986,11 +2986,11 @@ snapshots: - encoding - supports-color - '@prisma/fetch-engine@6.0.1': + '@prisma/fetch-engine@6.2.1': dependencies: - '@prisma/debug': 6.0.1 - '@prisma/engines-version': 5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e - '@prisma/get-platform': 6.0.1 + '@prisma/debug': 6.2.1 + '@prisma/engines-version': 6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69 + '@prisma/get-platform': 6.2.1 '@prisma/generator-helper@5.3.1': dependencies: @@ -3016,9 +3016,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@prisma/get-platform@6.0.1': + '@prisma/get-platform@6.2.1': dependencies: - '@prisma/debug': 6.0.1 + '@prisma/debug': 6.2.1 '@prisma/internals@5.3.1': dependencies: @@ -3070,13 +3070,13 @@ snapshots: '@prisma/prisma-schema-wasm@5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59': {} - '@scalar/express-api-reference@0.4.172': + '@scalar/express-api-reference@0.4.173': dependencies: - '@scalar/types': 0.0.24 + '@scalar/types': 0.0.25 '@scalar/openapi-types@0.1.5': {} - '@scalar/types@0.0.24': + '@scalar/types@0.0.25': dependencies: '@scalar/openapi-types': 0.1.5 '@unhead/schema': 1.11.14 @@ -4890,9 +4890,9 @@ snapshots: prettier@3.4.2: {} - prisma-extension-kysely@3.0.0(@prisma/client@6.0.1(prisma@6.0.1)): + prisma-extension-kysely@3.0.0(@prisma/client@6.2.1(prisma@6.2.1)): dependencies: - '@prisma/client': 6.0.1(prisma@6.0.1) + '@prisma/client': 6.2.1(prisma@6.2.1) prisma-kysely@1.8.0: dependencies: @@ -4905,9 +4905,9 @@ snapshots: - encoding - supports-color - prisma@6.0.1: + prisma@6.2.1: dependencies: - '@prisma/engines': 6.0.1 + '@prisma/engines': 6.2.1 optionalDependencies: fsevents: 2.3.3 From 5fe6ce1d5caf16c0a887aefe451ac18dad306375 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:27:44 +0700 Subject: [PATCH 2/4] fix: filter not work --- src/controllers/04-invoice-controller.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/controllers/04-invoice-controller.ts b/src/controllers/04-invoice-controller.ts index 94794c3..5fbe720 100644 --- a/src/controllers/04-invoice-controller.ts +++ b/src/controllers/04-invoice-controller.ts @@ -107,7 +107,14 @@ export class InvoiceController extends Controller { }, }, ], - payment: pay === undefined ? undefined : pay ? { isNot: null } : null, + payment: + pay !== undefined + ? { + paymentStatus: pay + ? PaymentStatus.PaymentSuccess + : { not: PaymentStatus.PaymentSuccess }, + } + : undefined, quotationId, quotation: { registeredBranch: { From 67651eb213074903fc120377820801332898d6a7 Mon Sep 17 00:00:00 2001 From: Methapon Metanipat <162551568+Methapon-Frappet@users.noreply.github.com> Date: Tue, 21 Jan 2025 10:51:30 +0700 Subject: [PATCH 3/4] feat: debit note (#9) * fix: filter not work * feat: add debit note flag to quotation * feat: add debit note structure * refactor: change name to debit * refactor(quotation): only query quotation not debit note * feat: delete debit note * feat: get debit note by id * chore: add import * feat: debit note stats * feat: get debit note list * chore: add comment * refactor: add debit note filter to invoice * chore: migration * refactor: change attachment endpoint to explicit declare * add createDebitNote * feat: add quotation relation to get endpoint * fix: wrong query * fix data to create * feat: include debit note in relation * feat: handle delete file on delete data * feat: check if quotation exists * feat: add update payload * refactor: merge variable * feat: add update endpoint debit note * fix: quotation is not flagged as debit note * feat: add worker into debit note * feat: add update debit note with worker * fix: missing remark field * feat: auto invoice This commit automatically create debit note invoice and payment data. Debit note does not required to create invoice and do not have installments. * feat: set default get invoice param to only quotation * refactor: debit note param in payment/invoice * fixup! refactor: debit note param in payment/invoice * fix: product does not have any worker --------- Co-authored-by: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Co-authored-by: Kanjana --- .../migration.sql | 18 + prisma/schema.prisma | 15 +- src/controllers/04-invoice-controller.ts | 19 +- src/controllers/04-receipt-controller.ts | 6 +- src/controllers/05-payment-controller.ts | 6 +- src/controllers/05-quotation-controller.ts | 17 +- src/controllers/09-debit-note-controller.ts | 893 ++++++++++++++++++ 7 files changed, 951 insertions(+), 23 deletions(-) create mode 100644 prisma/migrations/20250113023733_add_debit_note/migration.sql create mode 100644 src/controllers/09-debit-note-controller.ts diff --git a/prisma/migrations/20250113023733_add_debit_note/migration.sql b/prisma/migrations/20250113023733_add_debit_note/migration.sql new file mode 100644 index 0000000..05a8671 --- /dev/null +++ b/prisma/migrations/20250113023733_add_debit_note/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - You are about to drop the `DebitNote` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "DebitNote" DROP CONSTRAINT "DebitNote_quotationId_fkey"; + +-- AlterTable +ALTER TABLE "Quotation" ADD COLUMN "debitNoteQuotationId" TEXT, +ADD COLUMN "isDebitNote" BOOLEAN NOT NULL DEFAULT false; + +-- DropTable +DROP TABLE "DebitNote"; + +-- AddForeignKey +ALTER TABLE "Quotation" ADD CONSTRAINT "Quotation_debitNoteQuotationId_fkey" FOREIGN KEY ("debitNoteQuotationId") REFERENCES "Quotation"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 218e903..51f30b8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1277,6 +1277,11 @@ model Quotation { discount Float @default(0) finalPrice Float + isDebitNote Boolean @default(false) + debitNoteQuotationId String? + debitNoteQuotation Quotation? @relation(name: "QuotationDebitNote", fields: [debitNoteQuotationId], references: [id]) + debitNote Quotation[] @relation(name: "QuotationDebitNote") + requestData RequestData[] createdAt DateTime @default(now()) @@ -1288,7 +1293,6 @@ model Quotation { invoice Invoice[] creditNote CreditNote[] - debitNote DebitNote[] } model QuotationPaySplit { @@ -1625,12 +1629,3 @@ model CreditNote { createdBy User? @relation(name: "CreditNoteCreatedByUser", fields: [createdByUserId], references: [id]) createdByUserId String? } - -model DebitNote { - id String @id @default(cuid()) - - quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade) - quotationId String - - // NOTE: create quotation but with flag debit note? -} diff --git a/src/controllers/04-invoice-controller.ts b/src/controllers/04-invoice-controller.ts index 5fbe720..ac90937 100644 --- a/src/controllers/04-invoice-controller.ts +++ b/src/controllers/04-invoice-controller.ts @@ -44,15 +44,22 @@ export class InvoiceController extends Controller { @Get("stats") @OperationId("getInvoiceStats") @Security("keycloak") - async getInvoiceStats(@Request() req: RequestWithUser, @Query() quotationId?: string) { + async getInvoiceStats( + @Request() req: RequestWithUser, + @Query() quotationOnly: boolean = true, + @Query() quotationId?: string, + @Query() debitNoteId?: string, + @Query() debitNoteOnly?: boolean, + ) { const where = { - quotationId, quotation: { + id: quotationId, + isDebitNote: debitNoteId || debitNoteOnly ? true : quotationOnly ? false : undefined, registeredBranch: { OR: permissionCondCompany(req.user), }, }, - }; + } satisfies Prisma.InvoiceWhereInput; const [pay, notPay] = await prisma.$transaction([ prisma.invoice.count({ @@ -83,7 +90,10 @@ export class InvoiceController extends Controller { @Query() page: number = 1, @Query() pageSize: number = 30, @Query() query: string = "", + @Query() quotationOnly: boolean = true, + @Query() debitNoteOnly?: boolean, @Query() quotationId?: string, + @Query() debitNoteId?: string, @Query() pay?: boolean, ) { const where: Prisma.InvoiceWhereInput = { @@ -115,8 +125,9 @@ export class InvoiceController extends Controller { : { not: PaymentStatus.PaymentSuccess }, } : undefined, - quotationId, quotation: { + id: quotationId || debitNoteId, + isDebitNote: debitNoteId || debitNoteOnly ? true : quotationOnly ? false : undefined, registeredBranch: { OR: permissionCondCompany(req.user), }, diff --git a/src/controllers/04-receipt-controller.ts b/src/controllers/04-receipt-controller.ts index c66b5de..d173821 100644 --- a/src/controllers/04-receipt-controller.ts +++ b/src/controllers/04-receipt-controller.ts @@ -17,13 +17,17 @@ export class ReceiptController extends Controller { @Request() req: RequestWithUser, @Query() page: number = 1, @Query() pageSize: number = 30, + @Query() quotationOnly: boolean = true, @Query() quotationId?: string, + @Query() debitNoteId?: string, + @Query() debitNoteOnly?: boolean, ) { const where: Prisma.PaymentWhereInput = { paymentStatus: "PaymentSuccess", invoice: { - quotationId, quotation: { + id: quotationId || debitNoteId, + isDebitNote: debitNoteId || debitNoteOnly ? true : quotationOnly ? false : undefined, registeredBranch: { OR: permissionCondCompany(req.user), }, diff --git a/src/controllers/05-payment-controller.ts b/src/controllers/05-payment-controller.ts index 4d36055..1177dba 100644 --- a/src/controllers/05-payment-controller.ts +++ b/src/controllers/05-payment-controller.ts @@ -45,12 +45,16 @@ export class QuotationPayment extends Controller { @Request() req: RequestWithUser, @Query() page: number = 1, @Query() pageSize: number = 30, + @Query() quotationOnly: boolean = true, @Query() quotationId?: string, + @Query() debitNoteId?: string, + @Query() debitNoteOnly?: boolean, ) { const where: Prisma.PaymentWhereInput = { invoice: { - quotationId, quotation: { + id: quotationId || debitNoteId, + isDebitNote: debitNoteId || debitNoteOnly ? true : quotationOnly ? false : undefined, registeredBranch: { OR: permissionCondCompany(req.user), }, diff --git a/src/controllers/05-quotation-controller.ts b/src/controllers/05-quotation-controller.ts index a85d8aa..be18cc8 100644 --- a/src/controllers/05-quotation-controller.ts +++ b/src/controllers/05-quotation-controller.ts @@ -174,6 +174,7 @@ export class QuotationController extends Controller { by: "quotationStatus", where: { registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, + isDebitNote: false, }, }); @@ -213,6 +214,7 @@ export class QuotationController extends Controller { }, }, ]), + isDebitNote: false, code, payCondition, registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, @@ -332,6 +334,7 @@ export class QuotationController extends Controller { }, }, }, + debitNote: true, productServiceList: { include: { service: { @@ -363,7 +366,7 @@ export class QuotationController extends Controller { createdBy: true, updatedBy: true, }, - where: { id: quotationId }, + where: { id: quotationId, isDebitNote: false }, }); if (!record) throw notFoundError("Quotation"); @@ -627,7 +630,7 @@ export class QuotationController extends Controller { }, }, }, - where: { id: quotationId }, + where: { id: quotationId, isDebitNote: false }, }); if (!record) throw notFoundError("Quotation"); @@ -837,7 +840,7 @@ export class QuotationController extends Controller { select: { productServiceList: true }, }, }, - where: { id: quotationId }, + where: { id: quotationId, isDebitNote: false }, data: { ...rest, ...price, @@ -886,7 +889,7 @@ export class QuotationController extends Controller { include: { registeredBranch: { include: branchRelationPermInclude(req.user) }, }, - where: { id: quotationId }, + where: { id: quotationId, isDebitNote: false }, }); if (!record) throw notFoundError("Quotation"); @@ -937,7 +940,7 @@ export class QuotationActionController extends Controller { }, }, }, - where: { id: quotationId }, + where: { id: quotationId, isDebitNote: false }, }), tx.employee.findMany({ where: { id: { in: ids.employee } }, @@ -995,7 +998,7 @@ export class QuotationActionController extends Controller { update: { value: { increment: quotation.worker.length } }, }); await tx.quotation.update({ - where: { id: quotationId }, + where: { id: quotationId, isDebitNote: false }, data: { quotationStatus: QuotationStatus.PaymentSuccess, // NOTE: change back if already complete or canceled worker: { @@ -1043,7 +1046,7 @@ export class QuotationFileController extends Controller { include: branchRelationPermInclude(user), }, }, - where: { id }, + where: { id, isDebitNote: false }, }); if (!data) throw notFoundError("Quotation"); await permissionCheck(user, data.registeredBranch); diff --git a/src/controllers/09-debit-note-controller.ts b/src/controllers/09-debit-note-controller.ts new file mode 100644 index 0000000..53c0db0 --- /dev/null +++ b/src/controllers/09-debit-note-controller.ts @@ -0,0 +1,893 @@ +import { + Body, + Controller, + Delete, + Get, + Head, + Path, + Post, + Put, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; +import { PayCondition, Prisma, QuotationStatus, Status } from "@prisma/client"; + +import prisma from "../db"; +import config from "../config.json"; + +import { RequestWithUser } from "../interfaces/user"; +import { + branchRelationPermInclude, + createPermCheck, + createPermCondition, +} from "../services/permission"; +import HttpError from "../interfaces/http-error"; +import HttpStatus from "../interfaces/http-status"; +import { + deleteFile, + deleteFolder, + fileLocation, + getFile, + getPresigned, + listFile, + setFile, +} from "../utils/minio"; +import { isUsedError, notFoundError, relationError } from "../utils/error"; +import { queryOrNot } from "../utils/relation"; +import { isSystem } from "../utils/keycloak"; +import { precisionRound } from "../utils/arithmetic"; + +const MANAGE_ROLES = [ + "system", + "head_of_admin", + "admin", + "head_of_accountant", + "accountant", + "head_of_sale", + "sale", +]; + +function globalAllow(user: RequestWithUser["user"]) { + const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; + return allowList.some((v) => user.roles?.includes(v)); +} + +// NOTE: permission condition/check in registeredBranch +const permissionCond = createPermCondition(globalAllow); +const permissionCondCompany = createPermCondition((_) => true); +const permissionCheck = createPermCheck(globalAllow); +const permissionCheckCompany = createPermCheck((_) => true); + +type DebitNoteCreate = { + quotationId: string; + agentPrice?: boolean; + discount?: number; + status?: Status; + payCondition: PayCondition; + dueDate: Date; + remark?: string | null; + + worker: ( + | string + | { + dateOfBirth: Date; + gender: string; + nationality: string; + namePrefix?: string; + firstName: string; + firstNameEN: string; + middleName?: string; + middleNameEN?: string; + lastName: string; + lastNameEN: string; + } + )[]; + + productServiceList: { + serviceId?: string; + workId?: string; + productId: string; + amount: number; + discount?: number; + installmentNo?: number; + workerIndex?: number[]; + }[]; +}; + +type DebitNoteUpdate = { + agentPrice?: boolean; + discount?: number; + status?: Status; + payCondition: PayCondition; + dueDate: Date; + remark?: string | null; + + worker: ( + | string + | { + dateOfBirth: Date; + gender: string; + nationality: string; + namePrefix?: string; + firstName: string; + firstNameEN: string; + middleName?: string; + middleNameEN?: string; + lastName: string; + lastNameEN: string; + } + )[]; + + productServiceList: { + serviceId?: string; + workId?: string; + productId: string; + amount: number; + discount?: number; + installmentNo?: number; + workerIndex?: number[]; + }[]; +}; + +const VAT_DEFAULT = config.vat; + +@Route("api/v1/debit-note") +@Tags("Debit Note") +export class DebitNoteController extends Controller { + @Get("stats") + @Security("keycloak") + async getDebitNoteStats(@Request() req: RequestWithUser, @Query() quotationId?: string) { + const result = await prisma.quotation.groupBy({ + _count: true, + by: "quotationStatus", + where: { + registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, + debitNoteQuotationId: quotationId, + isDebitNote: true, + }, + }); + + return result.reduce>((a, c) => { + a[c.quotationStatus.charAt(0).toLowerCase() + c.quotationStatus.slice(1)] = c._count; + return a; + }, {}); + } + + @Get() + @Security("keycloak") + async getDebitNoteList( + @Request() req: RequestWithUser, + @Query() page: number = 1, + @Query() pageSize: number = 30, + @Query() query: string = "", + @Query() quotationId?: string, + @Query() status?: QuotationStatus, + @Query() payCondition?: PayCondition, + @Query() includeRegisteredBranch?: boolean, + @Query() code?: string, + ) { + return await this.getDebitNoteListByCriteria( + req, + page, + pageSize, + query, + quotationId, + status, + payCondition, + includeRegisteredBranch, + code, + ); + } + + // NOTE: only when needed or else remove this and implement in getCreditNoteList + @Post("list") + @Security("keycloak") + async getDebitNoteListByCriteria( + @Request() req: RequestWithUser, + @Query() page: number = 1, + @Query() pageSize: number = 30, + @Query() query: string = "", + @Query() quotationId?: string, + @Query() status?: QuotationStatus, + @Query() payCondition?: PayCondition, + @Query() includeRegisteredBranch?: boolean, + @Query() code?: string, + @Body() body?: {}, + ) { + const where = { + OR: queryOrNot(query, [ + { code: { contains: query, mode: "insensitive" } }, + { workName: { contains: query } }, + { + customerBranch: { + OR: [ + { code: { contains: query, mode: "insensitive" } }, + { customerName: { contains: query } }, + { firstName: { contains: query } }, + { firstNameEN: { contains: query } }, + { lastName: { contains: query } }, + { lastNameEN: { contains: query } }, + ], + }, + }, + ]), + isDebitNote: true, + code, + payCondition, + debitNoteQuotationId: quotationId, + registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, + quotationStatus: status, + } satisfies Prisma.QuotationWhereInput; + + const [result, total] = await prisma.$transaction([ + prisma.quotation.findMany({ + where, + include: { + _count: { + select: { worker: true }, + }, + registeredBranch: includeRegisteredBranch, + debitNoteQuotation: true, + customerBranch: { + include: { + customer: { + include: { registeredBranch: true }, + }, + }, + }, + invoice: { + include: { payment: true }, + }, + createdBy: true, + updatedBy: true, + }, + orderBy: { createdAt: "desc" }, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.quotation.count({ where }), + ]); + + return { result: result, page, pageSize, total }; + } + + @Get("{debitNoteId}") + @Security("keycloak") + async getDebitNote(@Request() req: RequestWithUser, @Path() debitNoteId: string) { + const record = await prisma.quotation.findUnique({ + include: { + _count: { + select: { worker: true }, + }, + registeredBranch: true, + customerBranch: { + include: { + province: true, + district: true, + subDistrict: true, + }, + }, + debitNoteQuotation: true, + worker: { + include: { + employee: { + include: { + employeePassport: { + orderBy: { expireDate: "desc" }, + }, + }, + }, + }, + }, + productServiceList: { + include: { + service: { + include: { + productGroup: true, + work: { + include: { + productOnWork: { + include: { + product: true, + }, + }, + }, + }, + }, + }, + work: true, + product: { + include: { productGroup: true }, + }, + + worker: true, + }, + }, + invoice: { + include: { + payment: true, + }, + }, + createdBy: true, + updatedBy: true, + }, + where: { id: debitNoteId, isDebitNote: true }, + }); + + if (!record) throw notFoundError("Debit Note"); + + return record; + } + + @Post() + @Security("keycloak", MANAGE_ROLES) + async createDebitNote(@Request() req: RequestWithUser, @Body() body: DebitNoteCreate) { + // NOTE: + // - when create debit note quotation must be added to debitNoteQuotation relation + // - when create debit note customer must be pulled from original quotation ลูกค้าจะต้องดึงจากใบเสนอราคาเดิม + // - when create debit note quotation status must be at least after payment was performed + const { productServiceList: _productServiceList, quotationId, ...rest } = body; + const ids = { + employee: body.worker.filter((v) => typeof v === "string"), + product: body.productServiceList + .map((v) => v.productId) + .filter((v, i, a) => a.findIndex((c) => c === v) === i), + work: body.productServiceList + .map((v) => v.workId || []) + .filter((v, i, a) => a.findIndex((c) => c === v) === i) + .flat(), + service: body.productServiceList + .map((v) => v.serviceId || []) + .filter((v, i, a) => a.findIndex((c) => c === v) === i) + .flat(), + }; + + const [employee, product, work, service] = await prisma.$transaction( + async (tx) => + await Promise.all([ + tx.employee.findMany({ where: { id: { in: ids.employee } } }), + tx.product.findMany({ where: { id: { in: ids.product } } }), + ids.work.length ? tx.work.findMany({ where: { id: { in: ids.work } } }) : null, + ids.service.length ? tx.service.findMany({ where: { id: { in: ids.service } } }) : null, + ]), + ); + if (ids.employee.length !== employee.length) throw relationError("Worker"); + if (ids.product.length !== product.length) throw relationError("Product"); + if (ids.work.length && ids.work.length !== work?.length) throw relationError("Work"); + if (ids.service.length && ids.service.length !== service?.length) { + throw relationError("Service"); + } + + return await prisma.$transaction(async (tx) => { + const master = await tx.quotation.findFirst({ + include: { + customerBranch: true, + }, + where: { + id: body.quotationId, + isDebitNote: false, + }, + }); + + if (!master) throw notFoundError("Quotation"); + + const customerBranch = master.customerBranch; + const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); + const lastEmployee = await tx.runningNo.upsert({ + where: { + key: `EMPLOYEE_${customerBranch.id}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, + }, + create: { + key: `EMPLOYEE_${customerBranch.id}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, + value: nonExistEmployee.length, + }, + update: { value: { increment: nonExistEmployee.length } }, + }); + + const newEmployee = await Promise.all( + nonExistEmployee.map((v, i) => + tx.employee.create({ + data: { + ...v, + code: `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`, + customerBranchId: customerBranch.id, + }, + }), + ), + ); + const sortedEmployeeId: string[] = []; + + while (body.worker.length > 0) { + const popExist = body.worker.shift(); + if (typeof popExist === "string") sortedEmployeeId.push(popExist); + else { + const popNew = newEmployee.shift(); + popNew && sortedEmployeeId.push(popNew.id); + } + } + + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + const lastQuotation = await tx.runningNo.upsert({ + where: { + key: `DEิBITNOTE_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}`, + }, + create: { + key: `DEิBITNOTE_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}`, + value: 1, + }, + update: { value: { increment: 1 } }, + }); + + const list = body.productServiceList.map((v, i) => { + 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 vat = p.calcVat + ? (pricePerUnit * (v.discount ? v.amount : 1) - (v.discount || 0)) * + VAT_DEFAULT * + (!v.discount ? v.amount : 1) + : 0; + + return { + order: i + 1, + productId: v.productId, + workId: v.workId, + serviceId: v.serviceId, + pricePerUnit, + amount: v.amount, + discount: v.discount || 0, + installmentNo: v.installmentNo, + vat, + worker: { + create: sortedEmployeeId + .filter((_, i) => !v.workerIndex || i in v.workerIndex) + .map((employeeId) => ({ employeeId })), + }, + }; + }); + + const price = list.reduce( + (a, c) => { + a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount); + a.totalDiscount = precisionRound(a.totalDiscount + c.discount); + a.vat = precisionRound(a.vat + c.vat); + a.vatExcluded = + c.vat === 0 + ? precisionRound( + a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT, + ) + : a.vatExcluded; + a.finalPrice = precisionRound( + Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0), + ); + + return a; + }, + { + totalPrice: 0, + totalDiscount: 0, + vat: 0, + vatExcluded: 0, + discount: body.discount, + finalPrice: 0, + }, + ); + + await Promise.all([ + tx.service.updateMany({ + where: { id: { in: ids.service }, status: Status.CREATED }, + data: { status: Status.ACTIVE }, + }), + tx.product.updateMany({ + where: { id: { in: ids.product }, status: Status.CREATED }, + data: { status: Status.ACTIVE }, + }), + ]); + + return await tx.quotation.create({ + include: { + productServiceList: { + include: { + service: { + include: { + productGroup: true, + work: { + include: { + productOnWork: { + include: { + product: true, + }, + }, + }, + }, + }, + }, + work: true, + product: { + include: { productGroup: true }, + }, + worker: true, + }, + }, + worker: true, + invoice: { + include: { + payment: true, + }, + }, + customerBranch: { + include: { customer: true }, + }, + _count: { + select: { productServiceList: true }, + }, + }, + data: { + ...rest, + ...price, + isDebitNote: true, + debitNoteQuotationId: quotationId, + quotationStatus: QuotationStatus.PaymentPending, + statusOrder: +(rest.status === "INACTIVE"), + code: `DN${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${lastQuotation.value.toString().padStart(6, "0")}`, + contactName: master?.contactName ?? "", + contactTel: master?.contactTel ?? "", + customerBranchId: master?.customerBranchId ?? "", + dueDate: body.dueDate, + payCondition: body.payCondition, + registeredBranchId: master?.registeredBranchId ?? "", + workName: master?.workName ?? "", + worker: { + createMany: { + data: sortedEmployeeId.map((v, i) => ({ + no: i, + employeeId: v, + })), + }, + }, + productServiceList: { + create: list, + }, + invoice: { + create: { + code: "", + amount: price.finalPrice, + payment: { + create: { + paymentStatus: "PaymentWait", + amount: price.finalPrice, + }, + }, + createdByUserId: req.user.sub, + }, + }, + createdByUserId: req.user.sub, + updatedByUserId: req.user.sub, + }, + }); + }); + } + + @Put("{debitNoteId}") + @Security("keycloak", MANAGE_ROLES) + async updateDebitNote( + @Request() req: RequestWithUser, + @Path() debitNoteId: string, + @Body() body: DebitNoteUpdate, + ) { + const record = await prisma.quotation.findUnique({ + include: { + registeredBranch: { include: branchRelationPermInclude(req.user) }, + customerBranch: { + include: { + customer: { + include: { + registeredBranch: { include: branchRelationPermInclude(req.user) }, + }, + }, + }, + }, + }, + where: { id: debitNoteId, isDebitNote: true }, + }); + + if (!record) throw notFoundError("Debit Note"); + + await permissionCheckCompany(req.user, record.registeredBranch); + + const { productServiceList: _productServiceList, ...rest } = body; + const ids = { + employee: body.worker.filter((v) => typeof v === "string"), + product: body.productServiceList + .map((v) => v.productId) + .filter((v, i, a) => a.findIndex((c) => c === v) === i), + work: body.productServiceList + .map((v) => v.workId || []) + .filter((v, i, a) => a.findIndex((c) => c === v) === i) + .flat(), + service: body.productServiceList + .map((v) => v.serviceId || []) + .filter((v, i, a) => a.findIndex((c) => c === v) === i) + .flat(), + }; + + const [employee, product, work, service] = await prisma.$transaction( + async (tx) => + await Promise.all([ + tx.employee.findMany({ where: { id: { in: ids.employee } } }), + tx.product.findMany({ where: { id: { in: ids.product } } }), + ids.work.length ? tx.work.findMany({ where: { id: { in: ids.work } } }) : null, + ids.service.length ? tx.service.findMany({ where: { id: { in: ids.service } } }) : null, + ]), + ); + if (ids.employee.length !== employee.length) throw relationError("Worker"); + if (ids.product.length !== product.length) throw relationError("Product"); + if (ids.work.length && ids.work.length !== work?.length) throw relationError("Work"); + if (ids.service.length && ids.service.length !== service?.length) { + throw relationError("Service"); + } + + return await prisma.$transaction(async (tx) => { + const customerBranch = record.customerBranch; + const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); + const lastEmployee = await tx.runningNo.upsert({ + where: { + key: `EMPLOYEE_${customerBranch.id}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, + }, + create: { + key: `EMPLOYEE_${customerBranch.id}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, + value: nonExistEmployee.length, + }, + update: { value: { increment: nonExistEmployee.length } }, + }); + + const newEmployee = await Promise.all( + nonExistEmployee.map((v, i) => + tx.employee.create({ + data: { + ...v, + code: `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`, + customerBranchId: customerBranch.id, + }, + }), + ), + ); + const sortedEmployeeId: string[] = []; + + while (body.worker.length > 0) { + const popExist = body.worker.shift(); + if (typeof popExist === "string") sortedEmployeeId.push(popExist); + else { + const popNew = newEmployee.shift(); + popNew && sortedEmployeeId.push(popNew.id); + } + } + const list = body.productServiceList.map((v, i) => { + 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 vat = p.calcVat + ? (pricePerUnit * (v.discount ? v.amount : 1) - (v.discount || 0)) * + VAT_DEFAULT * + (!v.discount ? v.amount : 1) + : 0; + + return { + order: i + 1, + productId: v.productId, + workId: v.workId, + serviceId: v.serviceId, + pricePerUnit, + amount: v.amount, + discount: v.discount || 0, + installmentNo: v.installmentNo, + vat, + worker: { + create: sortedEmployeeId + .filter((_, i) => !v.workerIndex || i in v.workerIndex) + .map((employeeId) => ({ employeeId })), + }, + }; + }); + + const price = list.reduce( + (a, c) => { + a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount); + a.totalDiscount = precisionRound(a.totalDiscount + c.discount); + a.vat = precisionRound(a.vat + c.vat); + a.vatExcluded = + c.vat === 0 + ? precisionRound( + a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT, + ) + : a.vatExcluded; + a.finalPrice = precisionRound( + Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0), + ); + + return a; + }, + { + totalPrice: 0, + totalDiscount: 0, + vat: 0, + vatExcluded: 0, + discount: body.discount, + finalPrice: 0, + }, + ); + + await Promise.all([ + tx.service.updateMany({ + where: { id: { in: ids.service }, status: Status.CREATED }, + data: { status: Status.ACTIVE }, + }), + tx.product.updateMany({ + where: { id: { in: ids.product }, status: Status.CREATED }, + data: { status: Status.ACTIVE }, + }), + ]); + + return await tx.quotation.update({ + include: { + productServiceList: { + include: { + service: { + include: { + productGroup: true, + work: { + include: { + productOnWork: { + include: { + product: true, + }, + }, + }, + }, + }, + }, + work: true, + product: { + include: { productGroup: true }, + }, + + worker: true, + }, + }, + worker: true, + customerBranch: { + include: { customer: true }, + }, + _count: { + select: { productServiceList: true }, + }, + }, + where: { id: debitNoteId, isDebitNote: true }, + data: { + ...rest, + ...price, + statusOrder: +(rest.status === "INACTIVE"), + worker: + sortedEmployeeId.length > 0 + ? { + deleteMany: { id: { notIn: sortedEmployeeId } }, + createMany: { + skipDuplicates: true, + data: sortedEmployeeId.map((v, i) => ({ + no: i, + employeeId: v, + })), + }, + } + : undefined, + productServiceList: list + ? { + deleteMany: {}, + create: list, + } + : undefined, + updatedByUserId: req.user.sub, + }, + }); + }); + } + + @Delete("{debitNoteId}") + @Security("keycloak", MANAGE_ROLES) + async deleteDebitNote(@Request() req: RequestWithUser, @Path() debitNoteId: string) { + const record = await prisma.quotation.findUnique({ + include: { + registeredBranch: { include: branchRelationPermInclude(req.user) }, + }, + where: { id: debitNoteId, isDebitNote: true }, + }); + + if (!record) throw notFoundError("Quotation"); + + await permissionCheck(req.user, record.registeredBranch); + + if (record.status !== Status.CREATED) throw isUsedError("Debit Note"); + + await Promise.all([deleteFolder(fileLocation.quotation.attachment(debitNoteId))]); + + return await prisma.quotation.delete({ + include: { + createdBy: true, + updatedBy: true, + }, + where: { id: debitNoteId, isDebitNote: true }, + }); + } +} + +@Route("api/v1/debit-note/{debitNoteId}") +@Tags("Debit Note") +export class DebitNoteFileController extends Controller { + async #checkPermission(user: RequestWithUser["user"], id: string) { + const data = await prisma.quotation.findUnique({ + include: { + registeredBranch: { + include: branchRelationPermInclude(user), + }, + }, + where: { id, isDebitNote: true }, + }); + if (!data) throw notFoundError("Debit Note"); + await permissionCheck(user, data.registeredBranch); + } + + @Get("attachment") + @Security("keycloak") + async listAttachment(@Request() req: RequestWithUser, @Path() debitNoteId: string) { + await this.#checkPermission(req.user, debitNoteId); + return await listFile(fileLocation.quotation.attachment(debitNoteId)); + } + + @Head("attachment/{name}") + async headAttachment( + @Request() req: RequestWithUser, + @Path() debitNoteId: string, + @Path() name: string, + ) { + return req.res?.redirect( + await getPresigned("head", fileLocation.quotation.attachment(debitNoteId, name)), + ); + } + + @Get("attachment/{name}") + @Security("keycloak") + async getAttachment( + @Request() req: RequestWithUser, + @Path() debitNoteId: string, + @Path() name: string, + ) { + await this.#checkPermission(req.user, debitNoteId); + return await getFile(fileLocation.quotation.attachment(debitNoteId, name)); + } + + @Put("attachment/{name}") + @Security("keycloak", MANAGE_ROLES) + async putAttachment( + @Request() req: RequestWithUser, + @Path() debitNoteId: string, + @Path() name: string, + ) { + await this.#checkPermission(req.user, debitNoteId); + return await setFile(fileLocation.quotation.attachment(debitNoteId, name)); + } + + @Delete("attachment/{name}") + @Security("keycloak", MANAGE_ROLES) + async deleteAttachment( + @Request() req: RequestWithUser, + @Path() debitNoteId: string, + @Path() name: string, + ) { + await this.#checkPermission(req.user, debitNoteId); + return await deleteFile(fileLocation.quotation.attachment(debitNoteId, name)); + } +} From 4e3c51d84b228f9a740da30749b1d14d8d8c5c5e Mon Sep 17 00:00:00 2001 From: Methapon Metanipat <162551568+Methapon-Frappet@users.noreply.github.com> Date: Wed, 22 Jan 2025 11:27:47 +0700 Subject: [PATCH 4/4] feat: doc template (#10) * feat: doc-template * fix: empty not converted to dash * feat: also return province, district and sub district * refactor: move some relation to outer * feat: add more function * chore: deps * feat: add more relation * feat: count employee by gender * feat: count all employee * feat: add more function * feat: get employment office * fix: error --------- Co-authored-by: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> --- package.json | 3 + pnpm-lock.yaml | 113 +++++- src/controllers/00-doc-template-controller.ts | 348 ++++++++++++++++++ src/utils/minio.ts | 5 + 4 files changed, 456 insertions(+), 13 deletions(-) create mode 100644 src/controllers/00-doc-template-controller.ts diff --git a/package.json b/package.json index 8d647b9..3f524a2 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/morgan": "^1.9.9", "cors": "^2.8.5", "cron": "^3.3.1", + "docx-templates": "^4.13.0", "dotenv": "^16.4.7", "express": "^4.21.2", "fast-jwt": "^4.0.6", @@ -46,6 +47,8 @@ "morgan": "^1.10.0", "prisma-extension-kysely": "^3.0.0", "promise.any": "^2.0.6", + "thai-baht-text": "^2.0.5", + "to-words": "^4.2.0", "tsoa": "^6.6.0", "winston": "^3.17.0", "winston-elasticsearch": "^0.19.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95f2fa6..b13e4c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: cron: specifier: ^3.3.1 version: 3.3.1 + docx-templates: + specifier: ^4.13.0 + version: 4.13.0 dotenv: specifier: ^16.4.7 version: 16.4.7 @@ -56,6 +59,12 @@ importers: promise.any: specifier: ^2.0.6 version: 2.0.6 + thai-baht-text: + specifier: ^2.0.5 + version: 2.0.5 + to-words: + specifier: ^4.2.0 + version: 4.2.0 tsoa: specifier: ^6.6.0 version: 6.6.0 @@ -86,7 +95,7 @@ importers: version: 6.2.1 prisma-kysely: specifier: ^1.8.0 - version: 1.8.0 + version: 1.8.0(encoding@0.1.13) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.17.10)(typescript@5.7.2) @@ -902,6 +911,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + docx-templates@4.13.0: + resolution: {integrity: sha512-tTmR3WhROYctuyVReQ+PfCU3zprmC45/VuSVzn8EjovzpRkXYUdXiDatB9M8pasj0V+wuuOyY8bcSHvlQ2GNag==} + engines: {node: '>=6'} + dotenv@16.0.3: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} @@ -944,6 +957,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -1267,6 +1283,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1281,6 +1301,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-in-the-middle@1.4.2: resolution: {integrity: sha512-9WOz1Yh/cvO/p69sxRmhyQwrIGGSp7EIdcb+fFNVi7CzQGQB8U1/1XrKVSbEd/GNOAeM0peJtmi7+qphe7NvAw==} @@ -1502,6 +1525,9 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -1521,6 +1547,9 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -1884,6 +1913,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -2112,6 +2144,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.3.0: + resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} + sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} @@ -2147,6 +2182,9 @@ packages: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -2343,6 +2381,9 @@ packages: text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + thai-baht-text@2.0.5: + resolution: {integrity: sha512-xTb1marcZhO7PFm2mBPKDJgtMg3crT8uCNHZIbPaV+B/R9LNWS1wBPg4DULtSgZSllsdmEObPzefxOeKAb6X+Q==} + through2@4.0.2: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} @@ -2357,6 +2398,10 @@ packages: to-source-code@1.0.2: resolution: {integrity: sha512-YzWtjmNIf3E75eZYa7m1SCyl0vgOGoTzdpH3svfa8SUm5rqTgl9hnDolrAGOghCF9P2gsITXQoMrlujOoz+RPw==} + to-words@4.2.0: + resolution: {integrity: sha512-KY2WEyu1ZVQ9h44Ac3w/E3i59ne873opFQZ8PXU3L7PWzsl8IjgQvSyogCqhCz+FFRCYEFJ1ERAeSB1Mu5sNjw==} + engines: {node: '>=12.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -2963,7 +3008,7 @@ snapshots: '@prisma/fetch-engine': 6.2.1 '@prisma/get-platform': 6.2.1 - '@prisma/fetch-engine@5.3.1': + '@prisma/fetch-engine@5.3.1(encoding@0.1.13)': dependencies: '@prisma/debug': 5.3.1 '@prisma/get-platform': 5.3.1 @@ -2974,7 +3019,7 @@ snapshots: http-proxy-agent: 7.0.0 https-proxy-agent: 7.0.2 kleur: 4.1.5 - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.13) p-filter: 2.1.0 p-map: 4.0.0 p-retry: 4.6.2 @@ -3020,19 +3065,19 @@ snapshots: dependencies: '@prisma/debug': 6.2.1 - '@prisma/internals@5.3.1': + '@prisma/internals@5.3.1(encoding@0.1.13)': dependencies: '@antfu/ni': 0.21.8 '@opentelemetry/api': 1.4.1 '@prisma/debug': 5.3.1 '@prisma/engines': 5.3.1 - '@prisma/fetch-engine': 5.3.1 + '@prisma/fetch-engine': 5.3.1(encoding@0.1.13) '@prisma/generator-helper': 5.3.1 '@prisma/get-platform': 5.3.1 '@prisma/prisma-schema-wasm': 5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59 archiver: 5.3.2 arg: 5.0.2 - checkpoint-client: 1.1.27 + checkpoint-client: 1.1.27(encoding@0.1.13) cli-truncate: 2.1.0 dotenv: 16.0.3 escape-string-regexp: 4.0.0 @@ -3048,7 +3093,7 @@ snapshots: is-wsl: 2.2.0 kleur: 4.1.5 new-github-issue-url: 0.2.1 - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.13) npm-packlist: 5.1.3 open: 7.4.2 p-map: 4.0.0 @@ -3508,13 +3553,13 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - checkpoint-client@1.1.27: + checkpoint-client@1.1.27(encoding@0.1.13): dependencies: ci-info: 3.8.0 env-paths: 2.2.1 make-dir: 4.0.0 ms: 2.1.3 - node-fetch: 2.6.12 + node-fetch: 2.6.12(encoding@0.1.13) uuid: 9.0.0 transitivePeerDependencies: - encoding @@ -3730,6 +3775,11 @@ snapshots: dependencies: path-type: 4.0.0 + docx-templates@4.13.0: + dependencies: + jszip: 3.10.1 + sax: 1.3.0 + dotenv@16.0.3: {} dotenv@16.4.7: {} @@ -3802,6 +3852,11 @@ snapshots: encodeurl@2.0.0: {} + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + end-of-stream@1.4.4: dependencies: once: 1.4.0 @@ -4263,6 +4318,11 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + ieee754@1.2.1: {} ignore-by-default@1.0.1: {} @@ -4273,6 +4333,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + import-in-the-middle@1.4.2: dependencies: acorn: 8.14.0 @@ -4480,6 +4542,13 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + kleur@3.0.3: {} kleur@4.1.5: {} @@ -4492,6 +4561,10 @@ snapshots: dependencies: readable-stream: 2.3.8 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lilconfig@2.1.0: {} lines-and-columns@1.2.4: {} @@ -4691,13 +4764,17 @@ snapshots: next-line@1.1.0: optional: true - node-fetch@2.6.12: + node-fetch@2.6.12(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 - node-fetch@2.7.0: + node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 nodemon@3.1.9: dependencies: @@ -4838,6 +4915,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.26.2 @@ -4894,11 +4973,11 @@ snapshots: dependencies: '@prisma/client': 6.2.1(prisma@6.2.1) - prisma-kysely@1.8.0: + prisma-kysely@1.8.0(encoding@0.1.13): dependencies: '@mrleebo/prisma-ast': 0.7.0 '@prisma/generator-helper': 5.3.1 - '@prisma/internals': 5.3.1 + '@prisma/internals': 5.3.1(encoding@0.1.13) typescript: 5.7.2 zod: 3.24.1 transitivePeerDependencies: @@ -5097,6 +5176,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.3.0: {} + sax@1.4.1: {} secure-json-parse@2.7.0: {} @@ -5150,6 +5231,8 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} shallow-clone-shim@2.0.0: @@ -5372,6 +5455,8 @@ snapshots: text-hex@1.0.0: {} + thai-baht-text@2.0.5: {} + through2@4.0.2: dependencies: readable-stream: 3.6.2 @@ -5389,6 +5474,8 @@ snapshots: is-nil: 1.0.1 optional: true + to-words@4.2.0: {} + toidentifier@1.0.1: {} touch@3.1.1: {} diff --git a/src/controllers/00-doc-template-controller.ts b/src/controllers/00-doc-template-controller.ts new file mode 100644 index 0000000..b7d1a03 --- /dev/null +++ b/src/controllers/00-doc-template-controller.ts @@ -0,0 +1,348 @@ +import createReport from "docx-templates"; +import ThaiBahtText from "thai-baht-text"; +import { District, Province, SubDistrict } from "@prisma/client"; +import { Readable } from "node:stream"; +import { Controller, Get, Path, Query, Route } from "tsoa"; +import prisma from "../db"; +import { notFoundError } from "../utils/error"; +import HttpError from "../interfaces/http-error"; +import HttpStatus from "../interfaces/http-status"; +import { getFileBuffer, listFile } from "../utils/minio"; + +const quotationData = (id: string) => + prisma.quotation.findFirst({ + where: { id, isDebitNote: false }, + include: { + registeredBranch: { + include: { + province: true, + district: true, + subDistrict: true, + headOffice: { + include: { + province: true, + district: true, + subDistrict: true, + }, + }, + }, + }, + customerBranch: { + include: { + customer: true, + province: true, + district: true, + subDistrict: true, + }, + }, + worker: { + include: { + employee: { + include: { + province: true, + district: true, + subDistrict: true, + employeePassport: { + orderBy: { expireDate: "desc" }, + }, + employeeWork: true, + }, + }, + }, + }, + productServiceList: { + include: { + work: true, + product: true, + service: true, + }, + }, + }, + }); + +@Route("api/v1/doc-template") +export class DocTemplateController extends Controller { + @Get() + async getTemplate() { + return await listFile(`doc-template/`); + } + + @Get("{documentTemplate}") + async getDocument( + @Path() documentTemplate: string, + @Query() data: string, + @Query() dataId: string, + @Query() dataOnly?: boolean, + ): Promise> { + let record: Record; + + switch (data) { + case "quotation": + record = await quotationData(dataId).then(async (quotation) => + replaceEmptyField({ + quotation, + customerBranch: quotation?.customerBranch, + registeredBranch: quotation?.registeredBranch, + employee: quotation?.worker.map((item) => item.employee), + employeeCount: { + all: quotation?.worker.length, + male: quotation?.worker.filter((item) => item.employee.gender === "male").length, + female: quotation?.worker.filter((item) => item.employee.gender === "female").length, + }, + employmentOffice: + quotation && quotation.customerBranch.districtId + ? await prisma.employmentOffice.findFirst({ + where: { + OR: [ + { + province: { + district: { some: { id: quotation.customerBranch.districtId } }, + }, + district: { none: {} }, + }, + { + district: { + some: { districtId: quotation.customerBranch.districtId }, + }, + }, + ], + }, + orderBy: [{ provinceId: "asc" }, { id: "asc" }], + }) + : undefined, + }), + ); + break; + default: + throw new HttpError(HttpStatus.BAD_REQUEST, "No data for template", "noDataTemplate"); + } + + if (!data) throw notFoundError("Data"); + if (dataOnly) return record; + + const template = await getFileBuffer(`doc-template/${documentTemplate}`); + + if (!data) Readable.from(template); + + const report = await createReport({ + template, + data: record, + additionalJsContext: { + addressFull, + addressFullTH: (addr: FullAddress) => addressFull(addr, "th"), + addressFullEN: (addr: FullAddress) => addressFull(addr, "en"), + gender, + genderTH: (text: string) => gender(text, "th"), + genderEN: (text: string) => gender(text, "en"), + businessType, + businessTypeEN: (text: string) => businessType(text, "en"), + businessTypeTH: (text: string) => businessType(text, "th"), + namePrefix, + namePrefixEN: (text: string) => namePrefix(text, "en"), + namePrefixTH: (text: string) => namePrefix(text, "th"), + jobPosition, + jobPositionEN: (text: string) => jobPosition(text, "en"), + jobPositionTH: (text: string) => jobPosition(text, "th"), + nationality, + nationalityEN: (text: string) => nationality(text, "en"), + nationalityTH: (text: string) => nationality(text, "th"), + thaiBahtText: (input: string | number) => { + ThaiBahtText(typeof input === "string" ? input.replaceAll(",", "") : input); + }, + }, + }).then(Buffer.from); + + return Readable.from(report); + } +} + +function replaceEmptyField(data: T): T { + return JSON.parse(JSON.stringify(data).replace(/null|\"\"/g, '"\-"')); +} + +type FullAddress = { + address: string; + addressEN: string; + moo?: string; + mooEN?: string; + soi?: string; + soiEN?: string; + street?: string; + streetEN?: string; + province?: Province | null; + district?: District | null; + subDistrict?: SubDistrict | null; + en?: boolean; +}; + +function addressFull(addr: FullAddress, lang: "th" | "en" = "en") { + let fragments: string[]; + switch (lang) { + case "th": + fragments = [`${addr.address},`]; + if (addr.moo) fragments.push(`หมู่ ${addr.moo},`); + if (addr.soi) fragments.push(`ซอย ${addr.soi},`); + if (addr.street) fragments.push(`ถนน${addr.street},`); + + if (addr.subDistrict) { + fragments.push(`${addr.province?.id === "10" ? "แขวง" : "ตำบล"}${addr.subDistrict.name},`); + } + if (addr.district) { + fragments.push(`${addr.province?.id === "10" ? "เขต" : "อำเภอ"}${addr.district.name},`); + } + if (addr.province) fragments.push(`จังหวัด${addr.province.name},`); + + break; + default: + fragments = [`${addr.addressEN},`]; + if (addr.mooEN) fragments.push(`Moo ${addr.mooEN},`); + if (addr.soiEN) fragments.push(`Soi ${addr.soiEN},`); + if (addr.streetEN) fragments.push(`${addr.streetEN} Rd.`); + + if (addr.subDistrict) { + fragments.push(`${addr.subDistrict.nameEN} sub-district,`); + } + if (addr.district) fragments.push(`${addr.district.nameEN} district,`); + if (addr.province) fragments.push(`${addr.province.nameEN},`); + break; + } + + return fragments.join(" "); +} + +function gender(text: string, lang: "th" | "en" = "en") { + switch (lang) { + case "th": + return { male: "ชาย", female: "หญิง" }[text] || text; + default: + text.charAt(0).toUpperCase() + text.slice(1); + } +} + +function businessType(text: string, lang: "th" | "en" = "en") { + switch (lang) { + case "th": + return ( + { + ["fisheries"]: "ประมง", + ["continuous-fisheries"]: "ต่อเนื่องประมงทะเล", + ["agriculture"]: "เกษตรและปศุสัตว์", + ["construction"]: "กิจการก่อสร้าง", + ["domesticHelper"]: "ผู้รับใช้ในบ้าน", + ["continuousAgriculture"]: "กิจการต่อเนื่องการเกษตร", + ["continuousButchery"]: "ต่อเนื่องปศุสัตว์โรงฆ่าสัตว์ ชำแหละ", + ["recycling"]: "กิจการรีไซเคิล", + ["mining"]: "เหมืองแร่/เหมืองหิน", + ["metal"]: "จำหน่ายผลิตภัณฑ์โลหะ", + ["food"]: "จำหน่ายอาหารและเครื่องดื่ม", + ["soilBasedProducts"]: "ผลิตหรือจำหน่ายผลิตภัณฑ์จากดิน", + ["constructionMaterials"]: "ผลิตหรือจำหน่ายวัสดุก่อสร้าง", + ["stone"]: "แปรรูปหิน", + ["cloth"]: "ผลิตหรือจำหน่ายเสื้อผ้าสำเร็จรูป", + ["plastic"]: "ผลิตหรือจำหน่ายผลิตภัณฑ์พลาสติก", + ["paper"]: "ผลิตหรือจำหน่ายผลิตภัณฑ์กระดาษ", + ["electronics"]: "ผลิตหรือจำหน่ายผลิตภัณฑ์อิเล็กทรอนิกส์", + ["transport"]: "ขนถ่ายสินค้าทางบก น้ำ คลังสินค้า", + ["market"]: "ค้าส่ง ค้าปลีก แผงลอย", + ["car"]: "อู่ซ่อมรถ ล้าง อัดฉีด", + ["fuel"]: "สถานีบริการน้ำมัน แก้ส เชื้อเพลิง", + ["institution"]: "สถานศึกษา มูลนิธิ สมาคม สถานพยาบาล", + ["service"]: "การให้บริการต่างๆ", + ["coordinator"]: "งานผู้ประสานงานด้านภาษากัมพูชา ลาว หรือเมียนมา", + ["seafood"]: "แปรรูปสัตว์น้ำ", + }[text] || text + ); + default: + return ( + { + ["fisheries"]: "Fisheries", + ["continuous-fisheries"]: "Continuous fisheries", + ["agriculture"]: "Agriculture and livestock", + ["construction"]: "Construction business", + ["domesticHelper"]: "Domestic helper", + ["continuousAgriculture"]: "Continuous agricultural operation", + ["continuousButchery"]: "Continuous livestock slaughter and processing ", + ["recycling"]: "Recycling business", + ["mining"]: "Mining/quarry", + ["metal"]: "Metal products distribution", + ["food"]: "Food and beverage distribution", + ["soilBasedProducts"]: "Manufacture or sell products made from soil", + ["constructionMaterials"]: "Manufacture or sell construction materials", + ["stone"]: "Stone processing", + ["cloth"]: "Manufacture or sell ready-to-wear clothing", + ["plastic"]: "Manufacture or sell plastic products", + ["paper"]: "Manufacture or sell paper products", + ["electronics"]: "Manufacture or sell electronic products", + ["transport"]: "Transport goods by land, water, and operate warehouses", + ["market"]: "Wholesale, retail, floating panels", + ["car"]: "Auto repair shop, car wash, and detailing", + ["fuel"]: "Gas station, fuel station, and service station", + ["institution"]: "Educational institution, foundation, association, hospital", + ["service"]: "Various services", + ["coordinator"]: "Coordinator for Khmer, Laos, or Myanmar language services", + ["seafood"]: "Processing seafood", + }[text] || text + ); + } +} + +function namePrefix(text: string, lang: "th" | "en" = "en") { + switch (lang) { + case "th": + return { mr: "นาย", mrs: "นาง", miss: "นางสาว" }[text] || text; + default: + text.charAt(0).toUpperCase() + text.slice(1); + } +} + +function nationality(text: string, lang: "th" | "en" = "en") { + switch (lang) { + case "th": + return ( + { + ["THA"]: "ไทย", + ["MMR"]: "เมียนมา", + ["LAO"]: "ลาว", + ["KHM"]: "กัมพูชา", + ["VNM"]: "เวียดนาม", + ["PHL"]: "ฟิลิปปินส์", + ["CHN"]: "จีน", + }[text] || text + ); + default: + return ( + { + ["THA"]: "Thai", + ["MMR"]: "Myanmar", + ["LAO"]: "Laos", + ["KHM"]: "Khmer", + ["VNM"]: "Vietnam", + ["PHL"]: "Philippines", + ["CHN"]: "China", + }[text] || text + ); + } +} + +function jobPosition(text: string, lang: "th" | "en" = "en") { + switch (lang) { + case "th": + return ( + { + ["labourer"]: "กรรมกร", + ["boatsMechanic"]: "ช่างเครื่องยนต์ในเรือประมงทะเล", + ["domesticHelper"]: "ผู้รับใช้ในบ้าน", + ["coordinator"]: "งานผู้ประสานงานด้านภาษากัมพูชา ลาว หรือเมียนมา", + }[text] || text + ); + default: + return ( + { + labourer: "Labourer", + boatsMechanic: "Marine engine mechanic on fishing boats", + domesticHelper: "Domestic helper", + coordinator: "Coordinator for Khmer, Laos, or Myanmar language services", + }[text] || text + ); + } +} diff --git a/src/utils/minio.ts b/src/utils/minio.ts index f5f37e6..ba02cac 100644 --- a/src/utils/minio.ts +++ b/src/utils/minio.ts @@ -1,3 +1,4 @@ +import { buffer } from "node:stream/consumers"; import minio from "../services/minio"; if (!process.env.MINIO_BUCKET) { @@ -26,6 +27,10 @@ export async function getFile(path: string, exp = 60 * 60) { return await minio.presignedGetObject(MINIO_BUCKET, path, exp); } +export async function getFileBuffer(path: string) { + return await minio.getObject(MINIO_BUCKET, path).then(buffer); +} + export async function setFile(path: string, exp = 6 * 60 * 60) { return await minio.presignedPutObject(MINIO_BUCKET, path, exp); }