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, whereDateQuery } from "../utils/relation"; import { isSystem } from "../utils/keycloak"; import { precisionRound } from "../utils/arithmetic"; const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "executive", "accountant", "branch_admin", "branch_manager", "branch_accountant", ]; function globalAllow(user: RequestWithUser["user"]) { const listAllowed = ["system", "head_of_admin", "admin", "executive", "accountant"]; return user.roles?.some((v) => listAllowed.includes(v)) || false; } const permissionCond = createPermCondition(globalAllow); const permissionCheck = createPermCheck(globalAllow); 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; otherNationality?: string | null; 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; otherNationality?: string | null; 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, @Query() startDate?: Date, @Query() endDate?: Date, ) { return await this.getDebitNoteListByCriteria( req, page, pageSize, query, quotationId, status, payCondition, includeRegisteredBranch, code, startDate, endDate, ); } // 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, @Query() startDate?: Date, @Query() endDate?: Date, @Body() body?: {}, ) { const where = { OR: queryOrNot(query, [ { code: { contains: query, mode: "insensitive" } }, { workName: { contains: query, mode: "insensitive" } }, { customerBranch: { OR: [ { code: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } }, { firstNameEN: { contains: query, mode: "insensitive" } }, { lastName: { contains: query, mode: "insensitive" } }, { lastNameEN: { contains: query, mode: "insensitive" } }, ], }, }, ]), isDebitNote: true, code, payCondition, debitNoteQuotationId: quotationId, registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, quotationStatus: status, ...whereDateQuery(startDate, endDate), } 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, 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") 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 permissionCheck(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 DebitNoteActionController 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); return data; } @Post("accept") @Security("keycloak", MANAGE_ROLES) async acceptDebitNote(@Request() req: RequestWithUser, @Path() debitNoteId: string) { const record = await this.#checkPermission(req.user, debitNoteId); if (record.quotationStatus !== QuotationStatus.Issued) { throw new HttpError(HttpStatus.BAD_REQUEST, "Already Accepted", "debitNoteAlreadyAccept"); } return await prisma.quotation.update({ where: { id: debitNoteId }, data: { quotationStatus: QuotationStatus.PaymentPending }, }); } } @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)); } }