import { PayCondition, Prisma, Status } from "@prisma/client"; 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 HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; type QuotationCreate = { status?: Status; payCondition: PayCondition; paySplitCount?: number; paySplit?: Date[]; payBillDate?: Date; workerCount: number; // EmployeeId or Create new employee worker: ( | string | { dateOfBirth: Date; gender: string; nationality: string; firstName: string; firstNameEN: string; lastName: string; lastNameEN: string; addressEN: string; address: string; zipCode: string; passportType: string; passportNumber: string; passportIssueDate: Date; passportExpiryDate: Date; passportIssuingCountry: string; passportIssuingPlace: string; previousPassportReference?: string; } )[]; customerBranchId: string; customerId: string; urgent?: boolean; service: { id: string; // Other fields will come from original data work: { id: string; // Name field will come from original data excluded?: boolean; product: { id: string; amount: number; /** * @maximum 1 * @minimum 0 */ discount: number; /** * @maximum 1 * @minimum 0 */ vat?: number; }[]; }[]; }[]; }; type QuotationUpdate = { status?: "ACTIVE" | "INACTIVE"; payCondition?: PayCondition; paySplitCount?: number; paySplit?: Date[]; payBillDate?: Date; workerCount?: number; // EmployeeId or Create new employee worker?: ( | string | { dateOfBirth: Date; gender: string; nationality: string; firstName: string; firstNameEN: string; lastName: string; lastNameEN: string; addressEN: string; address: string; zipCode: string; passportType: string; passportNumber: string; passportIssueDate: Date; passportExpiryDate: Date; passportIssuingCountry: string; passportIssuingPlace: string; previousPassportReference?: string; } )[]; customerBranchId?: string; customerId?: string; urgent?: boolean; service?: { id: string; // Other fields will come from original data work: { id: string; excluded?: boolean; // Name field will come from original data product: { id: string; /** * @isInt */ amount: number; /** * @maximum 1 * @minimum 0 */ discount: number; /** * @maximum 1 * @minimum 0 */ vat: number; }[]; }[]; }[]; }; const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "branch_manager", "head_of_account", "account", ]; function globalAllow(roles?: string[]) { return ["system", "head_of_admin", "admin", "branch_manager", "head_of_account"].some((v) => roles?.includes(v), ); } @Route("/api/v1/quotation") @Tags("Quotation") export class QuotationController extends Controller { @Get() @Security("keycloak") async getQuotationList(@Query() page: number = 1, @Query() pageSize: number = 30) { const [result, total] = await prisma.$transaction([ prisma.quotation.findMany({ include: { worker: true, service: { include: { _count: { select: { work: true } }, work: { include: { _count: { select: { productOnWork: true } }, productOnWork: { include: { product: true }, }, }, }, }, }, }, }), prisma.quotation.count(), ]); return { result: result, page, pageSize, total }; } @Get("{quotationId}") @Security("keycloak") async getQuotationById(@Path() quotationId: string) { const record = await prisma.quotation.findUnique({ include: { worker: true, service: { include: { _count: { select: { work: true } }, work: { include: { _count: { select: { productOnWork: true } }, productOnWork: { include: { product: true }, }, }, }, }, }, }, where: { id: quotationId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound"); } return record; } @Post() @Security("keycloak", MANAGE_ROLES) async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) { const existingEmployee = body.worker.filter((v) => typeof v === "string"); const serviceIdList = body.service.map((v) => v.id); const productIdList = body.service.flatMap((a) => a.work.flatMap((b) => b.product.map((c) => c.id)), ); const [customer, customerBranch, employee, service, product] = await prisma.$transaction([ prisma.customer.findUnique({ where: { id: body.customerId }, }), prisma.customerBranch.findUnique({ include: { customer: true }, where: { id: body.customerBranchId }, }), prisma.employee.findMany({ where: { id: { in: existingEmployee } }, }), prisma.service.findMany({ include: { work: true }, where: { id: { in: serviceIdList } }, }), prisma.product.findMany({ where: { id: { in: productIdList } }, }), ]); if (serviceIdList.length !== service.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some service cannot be found.", "relationServiceNotFound", ); } if (productIdList.length !== product.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some product cannot be found.", "relationProductNotFound", ); } if (existingEmployee.length !== employee.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some worker(employee) cannot be found.", "relationWorkerNotFound", ); } if (!customer) throw new HttpError( HttpStatus.BAD_REQUEST, "Customer cannot be found.", "relationCustomerNotFound", ); if (!customerBranch) throw new HttpError( HttpStatus.BAD_REQUEST, "Customer Branch cannot be found.", "relationCustomerBranchNotFound", ); if (customerBranch.customerId !== customer.id) throw new HttpError( HttpStatus.BAD_REQUEST, "Customer conflict with customer branch.", "customerConflictCustomerBranch", ); const { service: _service, 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: 1, }, 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 + i}`.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 price = { totalPrice: 0, totalDiscount: 0, totalVat: 0 }; const restructureService = body.service.flatMap((a) => { const currentService = service.find((b) => b.id === a.id); if (!currentService) return []; // should not possible return { id: currentService.id, name: currentService.name, code: currentService.code, detail: currentService.detail, attributes: currentService.attributes as Prisma.JsonObject, work: a.work.flatMap((c) => { if (c.excluded) return []; const currentWork = currentService.work.find((d) => d.id === c.id); if (!currentWork) return []; // additional will get stripped return { id: currentWork.id, order: currentWork.order, name: currentWork.name, attributes: currentWork.attributes as Prisma.JsonObject, product: c.product.flatMap((e) => { const currentProduct = product.find((f) => f.id === e.id); if (!currentProduct) return []; // should not possible price.totalPrice += currentProduct.price * e.amount; price.totalDiscount += Math.round(currentProduct.price * e.amount * e.discount * 100) / 100; price.totalVat += Math.round( (currentProduct.price * e.amount - currentProduct.price * e.amount * e.discount) * (e.vat === undefined ? 0.07 : e.vat) * 100, ) / 100; return { ...e, vat: e.vat === undefined ? 0.07 : e.vat, pricePerUnit: currentProduct.price, }; }), }; }), }; }); 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 } }, }); return await tx.quotation.create({ include: { service: { include: { work: { include: { productOnWork: { include: { product: true }, }, }, }, }, }, paySplit: true, worker: true, customerBranch: { include: { customer: true }, }, _count: { select: { service: true }, }, }, data: { ...rest, 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, })), }, }, totalPrice: price.totalPrice, totalDiscount: price.totalDiscount, vat: price.totalVat, vatExcluded: 0, finalPrice: price.totalPrice - price.totalDiscount, paySplit: { createMany: { data: (rest.paySplit || []).map((v, i) => ({ no: i + 1, date: v, })), }, }, service: { create: restructureService.map((a) => ({ code: a.code, name: a.name, detail: a.detail, attributes: a.attributes, refServiceId: a.id, work: { create: a.work.map((b) => ({ order: b.order, name: b.name, attributes: b.attributes, productOnWork: { createMany: { data: b.product.map((v, i) => ({ productId: v.id, order: i + 1, vat: v.vat, amount: v.amount, discount: v.discount, pricePerUnit: v.pricePerUnit, })), }, }, })), }, })), }, 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: { customer: true }, where: { id: quotationId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound"); } const existingEmployee = body.worker?.filter((v) => typeof v === "string"); const serviceIdList = body.service?.map((v) => v.id); const productIdList = body.service?.flatMap((a) => a.work.flatMap((b) => b.product.map((c) => c.id)), ); const [customer, customerBranch, employee, service, product] = await prisma.$transaction( async (tx) => await Promise.all([ tx.customer.findFirst({ where: { id: body.customerId }, }), tx.customerBranch.findFirst({ include: { customer: true }, where: { id: body.customerBranchId }, }), body.worker ? tx.employee.findMany({ where: { id: { in: existingEmployee } }, }) : null, body.service ? tx.service.findMany({ include: { work: true }, where: { id: { in: serviceIdList } }, }) : null, body.service ? tx.product.findMany({ where: { id: { in: productIdList } }, }) : null, ]), ); if (serviceIdList?.length !== service?.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some service cannot be found.", "relationServiceNotFound", ); } if (productIdList?.length !== product?.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some product cannot be found.", "relationProductNotFound", ); } if (existingEmployee?.length !== employee?.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some worker(employee) cannot be found.", "relationWorkerNotFound", ); } if (!customer) throw new HttpError( HttpStatus.BAD_REQUEST, "Customer cannot be found.", "relationCustomerNotFound", ); if (!customerBranch) throw new HttpError( HttpStatus.BAD_REQUEST, "Customer Branch cannot be found.", "relationCustomerBranchNotFound", ); if (customerBranch.customerId !== customer.id) throw new HttpError( HttpStatus.BAD_REQUEST, "Customer conflict with customer branch.", "customerConflictCustomerBranch", ); const { service: _service, worker: _worker, ...rest } = body; return await prisma.$transaction(async (tx) => { const sortedEmployeeId: string[] = []; if (body.worker) { 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: 1, }, 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, }, }), ), ); 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 price = { totalPrice: 0, totalDiscount: 0, totalVat: 0 }; const restructureService = body.service?.flatMap((a) => { const currentService = service?.find((b) => b.id === a.id); if (!currentService) return []; // should not possible return { id: currentService.id, name: currentService.name, code: currentService.code, detail: currentService.detail, attributes: currentService.attributes as Prisma.JsonObject, work: a.work.flatMap((c) => { if (c.excluded) return []; const currentWork = currentService.work.find((d) => d.id === c.id); if (!currentWork) return []; // additional will get stripped return { id: currentWork.id, order: currentWork.order, name: currentWork.name, attributes: currentWork.attributes as Prisma.JsonObject, product: c.product.flatMap((e) => { const currentProduct = product?.find((f) => f.id === e.id); if (!currentProduct) return []; // should not possible price.totalPrice += currentProduct.price * e.amount; price.totalDiscount += Math.round(currentProduct.price * e.amount * e.discount * 100) / 100; price.totalVat += Math.round( (currentProduct.price * e.amount - currentProduct.price * e.amount * e.discount) * (e.vat === undefined ? 0.07 : e.vat) * 100, ) / 100; return { ...e, vat: e.vat === undefined ? 0.07 : e.vat, pricePerUnit: currentProduct.price, }; }), }; }), }; }); return await tx.quotation.update({ include: { service: { include: { work: { include: { productOnWork: { include: { product: true }, }, }, }, }, }, paySplit: true, worker: true, customerBranch: { include: { customer: true }, }, _count: { select: { service: true }, }, }, where: { id: quotationId }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), code: "", worker: sortedEmployeeId.length > 0 ? { deleteMany: { id: { notIn: sortedEmployeeId } }, createMany: { skipDuplicates: true, data: sortedEmployeeId.map((v, i) => ({ no: i, code: "", employeeId: v, })), }, } : undefined, totalPrice: body.service ? price.totalPrice : undefined, totalDiscount: body.service ? price.totalDiscount : undefined, vat: body.service ? price.totalVat : undefined, vatExcluded: body.service ? 0 : undefined, finalPrice: body.service ? price.totalPrice - price.totalDiscount : undefined, paySplit: rest.paySplit ? { deleteMany: {}, createMany: { data: (rest.paySplit || []).map((v, i) => ({ no: i + 1, date: v, })), }, } : undefined, service: body.service && restructureService ? { deleteMany: {}, create: restructureService.map((a) => ({ code: a.code, name: a.name, detail: a.detail, attributes: a.attributes, refServiceId: a.id, work: { create: a.work.map((b) => ({ order: b.order, name: b.name, attributes: b.attributes, productOnWork: { createMany: { data: b.product.map((v, i) => ({ productId: v.id, order: i + 1, vat: v.vat, amount: v.amount, discount: v.discount, pricePerUnit: v.pricePerUnit, })), }, }, })), }, })), } : undefined, updatedByUserId: req.user.sub, }, }); }); } @Delete("{quotationId}") @Security("keycloak", MANAGE_ROLES) async deleteQuotationById(@Path() quotationId: string) { const record = await prisma.quotation.findUnique({ where: { id: quotationId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound"); } if (record.status !== Status.CREATED) { throw new HttpError(HttpStatus.FORBIDDEN, "Quotation is in used.", "quotationInUsed"); } return await prisma.quotation.delete({ include: { createdBy: true, updatedBy: true, }, where: { id: quotationId }, }); } }