import { PayCondition, Prisma, Status } from "@prisma/client"; import config from "../config.json"; import { Body, Controller, Delete, Get, Path, Post, Put, Query, Request, Route, Security, Tags, } from "tsoa"; import { RequestWithUser } from "../interfaces/user"; import prisma from "../db"; import { branchRelationPermInclude, createPermCheck, createPermCondition, } from "../services/permission"; import { isSystem } from "../utils/keycloak"; import { isUsedError, notFoundError, relationError } from "../utils/error"; import { precisionRound } from "../utils/arithmetic"; type QuotationCreate = { status?: Status; actorName: string; workName: string; contactName: string; contactTel: string; documentReceivePoint: string; dueDate: Date; payCondition: PayCondition; paySplitCount?: number; paySplit?: { date: Date; amount: number }[]; payBillDate?: Date; // EmployeeId or Create new employee worker: ( | string | { dateOfBirth: Date; gender: string; nationality: string; namePrefix?: string; firstName: string; firstNameEN: string; middleName?: string; middleNameEN?: string; lastName: string; lastNameEN: string; } )[]; customerBranchId: string; urgent?: boolean; agentPrice?: boolean; discount?: number; productServiceList: { serviceId?: string; workId?: string; productId: string; amount: number; /** * @maximum 1 * @minimum 0 */ discount?: number; workerIndex?: number[]; }[]; }; type QuotationUpdate = { status?: "ACTIVE" | "INACTIVE"; actorName?: string; workName?: string; contactName?: string; contactTel?: string; documentReceivePoint?: string; dueDate?: Date; payCondition?: PayCondition; paySplitCount?: number; paySplit?: { date: Date; amount: number }[]; payBillDate?: Date; // EmployeeId or Create new employee worker?: ( | string | { dateOfBirth: Date; gender: string; nationality: string; namePrefix?: string; firstName: string; firstNameEN: string; middleName?: string; middleNameEN?: string; lastName: string; lastNameEN: string; } )[]; customerBranchId?: string; urgent?: boolean; discount?: number; productServiceList?: { serviceId?: string; workId?: string; productId: string; amount: number; /** * @maximum 1 * @minimum 0 */ discount?: number; workerIndex?: number[]; }[]; }; const VAT_DEFAULT = config.vat; const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "head_of_account", "account", "head_of_sale", "sale", ]; function globalAllow(user: RequestWithUser["user"]) { const allowList = ["system", "head_of_admin", "head_of_account", "head_of_sale"]; return allowList.some((v) => user.roles?.includes(v)); } const permissionCheck = createPermCheck(globalAllow); const permissionCond = createPermCondition(globalAllow); @Route("/api/v1/quotation") @Tags("Quotation") export class QuotationController extends Controller { @Get("stats") @Security("keycloak") async getProductStats(@Request() req: RequestWithUser) { const result = await prisma.quotation.groupBy({ _count: true, by: "payCondition", where: { customerBranch: { customer: { registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, }, }, }, }); return result.reduce>((a, c) => { a[c.payCondition] = c._count; return a; }, {}); } @Get() @Security("keycloak") async getQuotationList( @Request() req: RequestWithUser, @Query() page: number = 1, @Query() pageSize: number = 30, @Query() payCondition?: PayCondition, ) { const where = { payCondition, customerBranch: { customer: { registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, }, }, } satisfies Prisma.QuotationWhereInput; const [result, total] = await prisma.$transaction([ prisma.quotation.findMany({ where, include: { _count: { select: { worker: true }, }, customerBranch: true, paySplit: true, }, take: pageSize, skip: (page - 1) * pageSize, }), prisma.quotation.count({ where }), ]); return { result: result, page, pageSize, total }; } @Get("{quotationId}") @Security("keycloak") async getQuotationById(@Path() quotationId: string) { const record = await prisma.quotation.findUnique({ include: { _count: { select: { worker: true }, }, customerBranch: true, worker: { include: { employee: true }, }, productServiceList: { include: { product: true, work: true, service: true, worker: true, }, }, paySplit: true, }, where: { id: quotationId }, }); if (!record) throw notFoundError("Quotation"); return record; } @Post() @Security("keycloak", MANAGE_ROLES) async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) { 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 || []).flat(), service: body.productServiceList.map((v) => v.serviceId || []).flat(), }; const [customerBranch, employee, product, work, service] = await prisma.$transaction( async (tx) => await Promise.all([ tx.customerBranch.findUnique({ include: { customer: { include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }, }, where: { id: body.customerBranchId }, }), 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 (!customerBranch) throw relationError("Customer Branch"); 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"); } await permissionCheck(req.user, customerBranch.customer.registeredBranch); const { productServiceList: _productServiceList, worker: _worker, ...rest } = body; return await prisma.$transaction(async (tx) => { const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); const lastEmployee = await tx.runningNo.upsert({ where: { key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, }, create: { key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, value: nonExistEmployee.length, }, update: { value: { increment: nonExistEmployee.length } }, }); const newEmployee = await Promise.all( nonExistEmployee.map(async (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 currentDate = new Date().getDate(); const lastQuotation = await tx.runningNo.upsert({ where: { key: `QUOTATION_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${currentDate.toString().padStart(2, "0")}`, }, create: { key: `QUOTATION_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${currentDate.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 ? precisionRound(price / (1 + VAT_DEFAULT)) : price; const vat = precisionRound((pricePerUnit * v.amount - (v.discount || 0)) * VAT_DEFAULT); return { order: i + 1, productId: v.productId, workId: v.workId, serviceId: v.serviceId, pricePerUnit: product.find((p) => p.id === v.productId)?.[body.agentPrice ? "agentPrice" : "price"] || 0, amount: v.amount, discount: v.discount || 0, 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.finalPrice = precisionRound(a.totalPrice - a.totalDiscount + a.vat); return a; }, { totalPrice: 0, totalDiscount: 0, vat: 0, discount: body.discount, finalPrice: -(body.discount || 0), }, ); return await tx.quotation.create({ include: { productServiceList: { include: { product: true, work: true, service: true, }, }, paySplit: true, worker: true, customerBranch: { include: { customer: true }, }, _count: { select: { productServiceList: true }, }, }, data: { ...rest, ...price, statusOrder: +(rest.status === "INACTIVE"), code: `${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${currentDate.toString().padStart(2, "0")}${lastQuotation.value.toString().padStart(4, "0")}`, worker: { createMany: { data: sortedEmployeeId.map((v, i) => ({ no: i, code: "", employeeId: v, })), }, }, paySplit: { createMany: { data: (rest.paySplit || []).map((v, i) => ({ no: i + 1, ...v, })), }, }, productServiceList: { create: list, }, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, }, }); }); } @Put("{quotationId}") @Security("keycloak", MANAGE_ROLES) async editQuotation( @Request() req: RequestWithUser, @Path() quotationId: string, @Body() body: QuotationUpdate, ) { const record = await prisma.quotation.findUnique({ include: { customerBranch: { include: { customer: { include: { registeredBranch: { include: branchRelationPermInclude(req.user) }, }, }, }, }, }, where: { id: quotationId }, }); if (!record) throw notFoundError("Quotation"); 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 || []).flat(), service: body.productServiceList?.map((v) => v.serviceId || []).flat(), }; const [customerBranch, employee, product, work, service] = await prisma.$transaction( async (tx) => await Promise.all([ tx.customerBranch.findUnique({ include: { customer: { include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }, }, where: { id: body.customerBranchId }, }), 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 (body.customerBranchId && !customerBranch) throw relationError("Customer Branch"); if (ids.employee && ids.employee.length !== employee.length) throw relationError("Worker"); if (ids.product && ids.product.length !== product.length) throw relationError("Product"); if (ids.work && ids.work.length && ids.work.length !== work?.length) { throw relationError("Work"); } if (ids.service && ids.service.length && ids.service.length !== service?.length) { throw relationError("Service"); } await permissionCheck(req.user, record.customerBranch.customer.registeredBranch); if (customerBranch && record.customerBranchId !== body.customerBranchId) { await permissionCheck(req.user, customerBranch.customer.registeredBranch); } const { productServiceList: _productServiceList, worker: _worker, ...rest } = body; return await prisma.$transaction(async (tx) => { const sortedEmployeeId: string[] = []; const branchId = (customerBranch || record.customerBranch).id; const branchCode = (customerBranch || record.customerBranch).code; if (body.worker) { const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); const lastEmployee = await tx.runningNo.upsert({ where: { key: `EMPLOYEE_${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, }, create: { key: `EMPLOYEE_${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, value: 1, }, update: { value: { increment: nonExistEmployee.length } }, }); const newEmployee = await Promise.all( nonExistEmployee.map(async (v, i) => tx.employee.create({ data: { ...v, code: `${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`, customerBranchId: branchId, }, }), ), ); 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 = record.agentPrice ? p.agentPrice : p.price; const pricePerUnit = p.vatIncluded ? precisionRound(price / 1 + VAT_DEFAULT) : price; const vat = precisionRound((pricePerUnit * v.amount - (v.discount || 0)) * VAT_DEFAULT); return { order: i + 1, productId: v.productId, workId: v.workId, serviceId: v.serviceId, pricePerUnit: product.find((p) => p.id === v.productId)?.[ record.agentPrice ? "agentPrice" : "price" ] || 0, amount: v.amount, discount: v.discount || 0, 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.finalPrice = precisionRound(a.totalPrice - a.totalDiscount + a.vat); return a; }, { totalPrice: 0, totalDiscount: 0, vat: 0, discount: body.discount, finalPrice: -(body.discount || 0), }, ); return await tx.quotation.update({ include: { productServiceList: { include: { product: true, work: true, service: true, }, }, paySplit: true, worker: true, customerBranch: { include: { customer: true }, }, _count: { select: { productServiceList: true }, }, }, where: { id: quotationId }, 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, code: "", employeeId: v, })), }, } : undefined, paySplit: rest.paySplit ? { deleteMany: {}, createMany: { data: (rest.paySplit || []).map((v, i) => ({ no: i + 1, ...v, })), }, } : undefined, productServiceList: { deleteMany: {}, create: list, }, updatedByUserId: req.user.sub, }, }); }); } @Delete("{quotationId}") @Security("keycloak", MANAGE_ROLES) async deleteQuotationById(@Request() req: RequestWithUser, @Path() quotationId: string) { const record = await prisma.quotation.findUnique({ include: { customerBranch: { include: { customer: { include: { registeredBranch: { include: branchRelationPermInclude(req.user) }, }, }, }, }, }, where: { id: quotationId }, }); if (!record) throw notFoundError("Quotation"); await permissionCheck(req.user, record.customerBranch.customer.registeredBranch); if (record.status !== Status.CREATED) throw isUsedError("Quotation"); return await prisma.quotation.delete({ include: { createdBy: true, updatedBy: true, }, where: { id: quotationId }, }); } }