From 71ffd895f627d74552d287d64ff6fe13c7ea3796 Mon Sep 17 00:00:00 2001 From: Methapon Metanipat Date: Tue, 1 Oct 2024 15:36:45 +0700 Subject: [PATCH] refactor: update quotation structure and endpoint --- .../migration.sql | 59 ++ prisma/schema.prisma | 60 +-- src/controllers/05-quotation-controller.ts | 506 +++++++----------- 3 files changed, 262 insertions(+), 363 deletions(-) create mode 100644 prisma/migrations/20241001080358_update_quotation_structure/migration.sql diff --git a/prisma/migrations/20241001080358_update_quotation_structure/migration.sql b/prisma/migrations/20241001080358_update_quotation_structure/migration.sql new file mode 100644 index 0000000..32f4311 --- /dev/null +++ b/prisma/migrations/20241001080358_update_quotation_structure/migration.sql @@ -0,0 +1,59 @@ +/* + Warnings: + + - You are about to drop the `QuotationService` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `QuotationServiceWork` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `QuotationServiceWorkProduct` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "QuotationService" DROP CONSTRAINT "QuotationService_quotationId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotationService" DROP CONSTRAINT "QuotationService_refServiceId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotationServiceWork" DROP CONSTRAINT "QuotationServiceWork_serviceId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotationServiceWorkProduct" DROP CONSTRAINT "QuotationServiceWorkProduct_productId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotationServiceWorkProduct" DROP CONSTRAINT "QuotationServiceWorkProduct_workId_fkey"; + +-- DropTable +DROP TABLE "QuotationService"; + +-- DropTable +DROP TABLE "QuotationServiceWork"; + +-- DropTable +DROP TABLE "QuotationServiceWorkProduct"; + +-- CreateTable +CREATE TABLE "QuotationProductServiceList" ( + "id" TEXT NOT NULL, + "quotationId" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "vat" DOUBLE PRECISION NOT NULL, + "amount" INTEGER NOT NULL, + "discount" DOUBLE PRECISION NOT NULL, + "pricePerUnit" DOUBLE PRECISION NOT NULL, + "productId" TEXT NOT NULL, + "workId" TEXT, + "serviceId" TEXT, + + CONSTRAINT "QuotationProductServiceList_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "QuotationProductServiceList" ADD CONSTRAINT "QuotationProductServiceList_quotationId_fkey" FOREIGN KEY ("quotationId") REFERENCES "Quotation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuotationProductServiceList" ADD CONSTRAINT "QuotationProductServiceList_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuotationProductServiceList" ADD CONSTRAINT "QuotationProductServiceList_workId_fkey" FOREIGN KEY ("workId") REFERENCES "Work"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuotationProductServiceList" ADD CONSTRAINT "QuotationProductServiceList_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8be3fdd..5437f0a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -931,7 +931,7 @@ model Product { productGroupId String workProduct WorkProduct[] - quotationServiceWorkProduct QuotationServiceWorkProduct[] + quotationProductServiceList QuotationProductServiceList[] createdAt DateTime @default(now()) createdBy User? @relation(name: "ProductCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull) @@ -955,8 +955,8 @@ model Service { shared Boolean @default(false) selectedImage String? - work Work[] - quotationService QuotationService[] + work Work[] + quotationProductServiceList QuotationProductServiceList[] productGroup ProductGroup @relation(fields: [productGroupId], references: [id], onDelete: Cascade) productGroupId String @@ -989,7 +989,8 @@ model Work { updatedBy User? @relation(name: "WorkUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull) updatedByUserId String? - productOnWork WorkProduct[] + productOnWork WorkProduct[] + quotationProductServiceList QuotationProductServiceList[] } model WorkProduct { @@ -1045,10 +1046,10 @@ model Quotation { workerCount Int worker QuotationWorker[] - service QuotationService[] - urgent Boolean @default(false) + productServiceList QuotationProductServiceList[] + totalPrice Float totalDiscount Float vat Float @@ -1084,47 +1085,24 @@ model QuotationWorker { quotationId String } -model QuotationService { - id String @id @default(cuid()) - - code String - name String - detail String - attributes Json? - - work QuotationServiceWork[] - - refServiceId String - refService Service @relation(fields: [refServiceId], references: [id]) - - quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade) +model QuotationProductServiceList { + id String @id @default(cuid()) quotationId String -} + quotation Quotation @relation(fields: [quotationId], references: [id]) -model QuotationServiceWork { - id String @id @default(cuid()) - - order Int - name String - attributes Json? - - service QuotationService @relation(fields: [serviceId], references: [id], onDelete: Cascade) - serviceId String - - productOnWork QuotationServiceWorkProduct[] -} - -model QuotationServiceWorkProduct { - order Int - work QuotationServiceWork @relation(fields: [workId], references: [id], onDelete: Cascade) - workId String - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - productId String + order Int vat Float amount Int discount Float pricePerUnit Float - @@id([workId, productId]) + productId String + product Product @relation(fields: [productId], references: [id]) + + workId String? + work Work? @relation(fields: [workId], references: [id]) + + serviceId String? + service Service? @relation(fields: [serviceId], references: [id]) } diff --git a/src/controllers/05-quotation-controller.ts b/src/controllers/05-quotation-controller.ts index d6832d6..03f6ff4 100644 --- a/src/controllers/05-quotation-controller.ts +++ b/src/controllers/05-quotation-controller.ts @@ -64,28 +64,22 @@ type QuotationCreate = { 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; - }[]; - }[]; + productServiceList: { + serviceId?: string; + workId?: string; + productId: string; + amount: number; + /** + * @maximum 1 + * @minimum 0 + */ + discount: number; + pricePerUnit?: number; + /** + * @maximum 1 + * @minimum 0 + */ + vat?: number; }[]; }; @@ -129,31 +123,22 @@ type QuotationUpdate = { 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; - }[]; - }[]; + productServiceList?: { + serviceId?: string; + workId?: string; + productId: string; + amount: number; + /** + * @maximum 1 + * @minimum 0 + */ + discount: number; + pricePerUnit?: number; + /** + * @maximum 1 + * @minimum 0 + */ + vat?: number; }[]; }; @@ -202,14 +187,11 @@ export class QuotationController extends Controller { include: { customerBranch: true, worker: true, - service: { + productServiceList: { include: { - _count: { select: { work: true } }, - work: { - include: { - _count: { select: { productOnWork: true } }, - }, - }, + product: true, + work: true, + service: true, }, }, }, @@ -231,17 +213,11 @@ export class QuotationController extends Controller { worker: { include: { employee: true }, }, - service: { + productServiceList: { include: { - _count: { select: { work: true } }, - work: { - include: { - _count: { select: { productOnWork: true } }, - productOnWork: { - include: { product: true }, - }, - }, - }, + product: true, + work: true, + service: true, }, }, }, @@ -256,45 +232,49 @@ export class QuotationController extends Controller { @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 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, service, product] = await prisma.$transaction([ - prisma.customerBranch.findUnique({ - include: { - customer: { + const [customerBranch, employee, product, work, service] = await prisma.$transaction( + async (tx) => + await Promise.all([ + tx.customerBranch.findUnique({ include: { - registeredBranch: { - include: branchRelationPermInclude(req.user), + customer: { + include: { + registeredBranch: { + include: branchRelationPermInclude(req.user), + }, + }, }, }, - }, - }, - 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 } }, - }), - ]); + 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 (serviceIdList.length !== service.length) throw relationError("Service"); - if (productIdList.length !== product.length) throw relationError("Product"); - if (existingEmployee.length !== employee.length) throw relationError("Worker"); 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 { service: _service, worker: _worker, ...rest } = body; + const { productServiceList: _productServiceList, worker: _worker, ...rest } = body; return await prisma.$transaction(async (tx) => { const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); @@ -330,58 +310,6 @@ export class QuotationController extends Controller { } } - 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 ? VAT_DEFAULT : e.vat) * - 100, - ) / 100; - - return { - ...e, - vat: e.vat === undefined ? VAT_DEFAULT : e.vat, - pricePerUnit: currentProduct.price, - }; - }), - }; - }), - }; - }); - const currentYear = new Date().getFullYear(); const currentMonth = new Date().getMonth() + 1; const currentDate = new Date().getDate(); @@ -397,17 +325,46 @@ export class QuotationController extends Controller { update: { value: { increment: 1 } }, }); + const list = body.productServiceList.map((v, i) => ({ + order: i + 1, + productId: v.productId, + workId: v.workId, + serviceId: v.serviceId, + pricePerUnit: v.pricePerUnit || product.find((p) => p.id === v.productId)?.price || 0, + amount: v.amount, + discount: v.discount, + vat: v.vat || VAT_DEFAULT, + })); + + const price = list.reduce( + (a, c) => { + const price = c.pricePerUnit * c.amount; + const discount = price * c.discount; + const vat = (price - discount) * c.vat; + + a.totalPrice += price; + a.totalDiscount += discount; + a.vat += vat; + a.finalPrice += price - discount + vat; + + return a; + }, + { + totalPrice: 0, + totalDiscount: 0, + vat: 0, + vatExcluded: 0, + finalPrice: 0, + }, + ); + return await tx.quotation.create({ include: { - service: { + productServiceList: { include: { - work: { - include: { - productOnWork: { - include: { product: true }, - }, - }, - }, + product: true, + work: true, + service: true, }, }, paySplit: true, @@ -416,12 +373,13 @@ export class QuotationController extends Controller { include: { customer: true }, }, _count: { - select: { service: true }, + 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: { @@ -433,11 +391,6 @@ export class QuotationController extends Controller { })), }, }, - totalPrice: price.totalPrice, - totalDiscount: price.totalDiscount, - vat: price.totalVat, - vatExcluded: 0, - finalPrice: price.totalPrice - price.totalDiscount, paySplit: { createMany: { data: (rest.paySplit || []).map((v, i) => ({ @@ -446,34 +399,7 @@ export class QuotationController extends Controller { })), }, }, - 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, - })), - }, - }, - })), - }, - })), - }, + productServiceList: { create: list }, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, }, @@ -505,57 +431,53 @@ export class QuotationController extends Controller { if (!record) throw notFoundError("Quotation"); - 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 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, service, product] = await prisma.$transaction( + const [customerBranch, employee, product, work, service] = await prisma.$transaction( async (tx) => await Promise.all([ - body.customerBranchId - ? tx.customerBranch.findFirst({ + tx.customerBranch.findUnique({ + include: { + customer: { include: { - customer: { - include: { - registeredBranch: { include: branchRelationPermInclude(req.user) }, - }, + registeredBranch: { + include: branchRelationPermInclude(req.user), }, }, - where: { id: body.customerBranchId }, - }) - : null, - 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, + }, + }, + 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 (serviceIdList?.length !== service?.length) throw relationError("Service"); - if (productIdList?.length !== product?.length) throw relationError("Product"); - if (existingEmployee?.length !== employee?.length) throw relationError("Worker"); 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 { service: _service, worker: _worker, ...rest } = body; + const { productServiceList: _productServiceList, worker: _worker, ...rest } = body; return await prisma.$transaction(async (tx) => { const sortedEmployeeId: string[] = []; @@ -597,69 +519,46 @@ export class QuotationController extends Controller { } } - const price = { totalPrice: 0, totalDiscount: 0, totalVat: 0 }; + const list = body.productServiceList?.map((v, i) => ({ + order: i + 1, + productId: v.productId, + workId: v.workId, + serviceId: v.serviceId, + pricePerUnit: v.pricePerUnit || product.find((p) => p.id === v.productId)?.price || 0, + amount: v.amount, + discount: v.discount, + vat: v.vat || VAT_DEFAULT, + })); - const restructureService = body.service?.flatMap((a) => { - const currentService = service?.find((b) => b.id === a.id); + const price = list?.reduce( + (a, c) => { + const price = c.pricePerUnit * c.amount; + const discount = price * c.discount; + const vat = price - discount * c.vat; - if (!currentService) return []; // should not possible + a.totalPrice += price; + a.totalDiscount += discount; + a.vat += vat; + a.finalPrice += price - discount + vat; - 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 ? VAT_DEFAULT : e.vat) * - 100, - ) / 100; - - return { - ...e, - vat: e.vat === undefined ? VAT_DEFAULT : e.vat, - pricePerUnit: currentProduct.price, - }; - }), - }; - }), - }; - }); + return a; + }, + { + totalPrice: 0, + totalDiscount: 0, + vat: 0, + vatExcluded: 0, + finalPrice: 0, + }, + ); return await tx.quotation.update({ include: { - service: { + productServiceList: { include: { - work: { - include: { - productOnWork: { - include: { product: true }, - }, - }, - }, + product: true, + work: true, + service: true, }, }, paySplit: true, @@ -668,12 +567,13 @@ export class QuotationController extends Controller { include: { customer: true }, }, _count: { - select: { service: true }, + select: { productServiceList: true }, }, }, where: { id: quotationId }, data: { ...rest, + ...price, statusOrder: +(rest.status === "INACTIVE"), worker: sortedEmployeeId.length > 0 @@ -689,14 +589,6 @@ export class QuotationController extends Controller { }, } : 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: {}, @@ -708,40 +600,10 @@ export class QuotationController extends Controller { }, } : 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, - + productServiceList: { + deleteMany: {}, + create: list, + }, updatedByUserId: req.user.sub, }, });