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)); + } +}