From d0e207de7e9e5a1727812ff19a74fa598e895c33 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Thu, 18 Jul 2024 17:23:41 +0700 Subject: [PATCH 01/24] feat: add quotation structure --- prisma/schema.prisma | 127 +++++++++++++++++++++++- src/controllers/quotation-controller.ts | 108 ++++++++++++++++++++ 2 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 src/controllers/quotation-controller.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6431837..28d6c2b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -375,6 +375,8 @@ model User { productTypeUpdated ProductType[] @relation("ProductTypeUpdatedByUser") productCreated Product[] @relation("ProductCreatedByUser") productUpdated Product[] @relation("ProductUpdatedByUser") + quotationCreated Quotation[] @relation("QuotationCreatedByUser") + quotationUpdated Quotation[] @relation("QuotationUpdatedByUser") } enum CustomerType { @@ -405,7 +407,8 @@ model Customer { updatedBy User? @relation(name: "CustomerUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull) updatedByUserId String? - branch CustomerBranch[] + branch CustomerBranch[] + quotation Quotation[] } model CustomerBranch { @@ -462,7 +465,8 @@ model CustomerBranch { updatedBy User? @relation(name: "CustomerBranchUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull) updatedByUserId String? - employee Employee[] + employee Employee[] + quotation Quotation[] } model Employee { @@ -528,7 +532,8 @@ model Employee { employeeWork EmployeeWork[] employeeOtherInfo EmployeeOtherInfo[] - editHistory EmployeeHistory[] + editHistory EmployeeHistory[] + quotationWorker QuotationWorker[] } model EmployeeHistory { @@ -691,7 +696,8 @@ model Product { registeredBranchId String? registeredBranch Branch? @relation(fields: [registeredBranchId], references: [id]) - workProduct WorkProduct[] + workProduct WorkProduct[] + quotationServiceWorkProduct QuotationServiceWorkProduct[] createdAt DateTime @default(now()) createdBy User? @relation(name: "ProductCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull) @@ -767,3 +773,116 @@ model WorkProduct { @@id([workId, productId]) } + +enum PayCondition { + Full + Split + BillFull + BillSplit +} + +model Quotation { + id String @id @default(uuid()) + + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + + customerBranchId String + customerBranch CustomerBranch @relation(fields: [customerBranchId], references: [id]) + + code String + date DateTime @default(now()) + payCondition PayCondition + + paySplitCount Int? + paySplit QuotationPaySplit[] + + payBillDate DateTime? + + workerCount Int + worker QuotationWorker[] + + service QuotationService[] + + urgent Boolean @default(false) + + totalPrice Float + totalDiscount Float + vat Int + vatExcluded Int + + createdAt DateTime @default(now()) + createdBy User? @relation(name: "QuotationCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull) + createdByUserId String? + updatedAt DateTime @updatedAt + updatedBy User? @relation(name: "QuotationUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull) + updatedByUserId String? +} + +model QuotationPaySplit { + id String @id @default(uuid()) + + no Int + date DateTime + + quotation Quotation? @relation(fields: [quotationId], references: [id]) + quotationId String? +} + +model QuotationWorker { + id String @id @default(uuid()) + + no Int + code String + employee Employee @relation(fields: [employeeId], references: [id]) + employeeId String + quotation Quotation @relation(fields: [quotationId], references: [id]) + quotationId String +} + +model QuotationService { + id String @id @default(uuid()) + + code String + name String + detail String + attributes Json? + + status Status @default(CREATED) + statusOrder Int @default(0) + + work QuotationServiceWork[] + + quotation Quotation @relation(fields: [quotationId], references: [id]) + quotationId String +} + +model QuotationServiceWork { + id String @id @default(uuid()) + + order Int + name String + attributes Json? + + status Status @default(CREATED) + statusOrder Int @default(0) + + service QuotationService @relation(fields: [serviceId], references: [id]) + serviceId String + + productOnWork QuotationServiceWorkProduct[] +} + +model QuotationServiceWorkProduct { + order Int + work QuotationServiceWork @relation(fields: [workId], references: [id]) + workId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + productId String + + amount Int + discount Float + pricePerUnit Float + + @@id([workId, productId]) +} diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts new file mode 100644 index 0000000..77dd9c7 --- /dev/null +++ b/src/controllers/quotation-controller.ts @@ -0,0 +1,108 @@ +import { PayCondition, Status } from "@prisma/client"; +import { + Body, + Controller, + Delete, + Get, + Path, + Post, + Put, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; +import { RequestWithUser } from "../interfaces/user"; + +type QuotationCreate = { + status?: Status; + + payCondition: PayCondition; + + paySplitCount?: number; + paySplit?: Date[]; + + payBillDate?: number; + + workerCount: number; + workerId: string[]; // EmployeeId + + urgent?: boolean; + + service: { + id: string; + // Other fields will come from original data + work?: { + // Name field will come from original data + product: { + id: string; + amount: number; + discount: number; + pricePerUnit: number; + }[]; + }[]; + }; +}; + +type QuotationUpdate = { + status?: "ACTIVE" | "INACTIVE"; + + payCondition: PayCondition; + + paySplitCount?: number; + paySplit?: Date[]; + + payBillDate?: number; + + workerCount: number; + workerId: string[]; // EmployeeId + + urgent?: boolean; + + service: { + id: string; + // Other fields will come from original data + work?: { + // Name field will come from original data + product: { + id: string; + amount: number; + discount: number; + pricePerUnit: number; + }[]; + }[]; + }; +}; + +@Route("/api/v1/quotation") +@Tags("Quotation") +export class QuotationController extends Controller { + @Get("{quotationId}") + @Security("keycloak") + async getQuotationById( + @Path() quotationId: string, + @Query() page: number = 1, + @Query() pageSize: number = 30, + ) {} + + @Get() + @Security("keycloak") + async getQuotationList(@Query() page: number = 1, @Query() pageSize: number = 30) {} + + @Post() + @Security("keycloak") + async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) {} + + @Put("{quotationId}") + @Security("keycloak") + async editQuotation( + @Request() req: RequestWithUser, + @Path() quotationId: string, + @Body() body: QuotationUpdate, + ) {} + + @Delete("{quotationId}") + @Security("keycloak") + async deleteQuotationById(@Request() req: RequestWithUser, @Path() quotationId: string) {} +} From fe50a31e080d1adeac88c99aa227a2b845db59b9 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Fri, 19 Jul 2024 10:41:37 +0700 Subject: [PATCH 02/24] feat: add get quotation by id endpoint --- src/controllers/quotation-controller.ts | 103 +++++++++++++++++++++--- 1 file changed, 92 insertions(+), 11 deletions(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index 77dd9c7..f3622df 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -14,6 +14,9 @@ import { 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; @@ -26,14 +29,42 @@ type QuotationCreate = { payBillDate?: number; workerCount: number; - workerId: string[]; // EmployeeId + // 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?: { + work: { // Name field will come from original data product: { id: string; @@ -42,7 +73,7 @@ type QuotationCreate = { pricePerUnit: number; }[]; }[]; - }; + }[]; }; type QuotationUpdate = { @@ -56,14 +87,42 @@ type QuotationUpdate = { payBillDate?: number; workerCount: number; - workerId: string[]; // EmployeeId + // 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?: { + work: { // Name field will come from original data product: { id: string; @@ -72,7 +131,7 @@ type QuotationUpdate = { pricePerUnit: number; }[]; }[]; - }; + }[]; }; @Route("/api/v1/quotation") @@ -80,11 +139,33 @@ type QuotationUpdate = { export class QuotationController extends Controller { @Get("{quotationId}") @Security("keycloak") - async getQuotationById( - @Path() quotationId: string, - @Query() page: number = 1, - @Query() pageSize: number = 30, - ) {} + 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; + } @Get() @Security("keycloak") From a2dbaf29fb1201603b71cc4c6429d25b3fe51568 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Fri, 19 Jul 2024 10:46:28 +0700 Subject: [PATCH 03/24] feat: add quotation list endpoint (no query) --- src/controllers/quotation-controller.ts | 26 ++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index f3622df..dfa1554 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -169,7 +169,31 @@ export class QuotationController extends Controller { @Get() @Security("keycloak") - async getQuotationList(@Query() page: number = 1, @Query() pageSize: number = 30) {} + 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 }; + } @Post() @Security("keycloak") From 1cf1915209a94fc8fbac59d12f801f8a76e3f94d Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Fri, 19 Jul 2024 10:49:38 +0700 Subject: [PATCH 04/24] refactor: update structure --- prisma/schema.prisma | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 28d6c2b..1c4aec5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -790,6 +790,9 @@ model Quotation { customerBranchId String customerBranch CustomerBranch @relation(fields: [customerBranchId], references: [id]) + status Status @default(CREATED) + statusOrder Int @default(0) + code String date DateTime @default(now()) payCondition PayCondition @@ -848,9 +851,6 @@ model QuotationService { detail String attributes Json? - status Status @default(CREATED) - statusOrder Int @default(0) - work QuotationServiceWork[] quotation Quotation @relation(fields: [quotationId], references: [id]) @@ -864,9 +864,6 @@ model QuotationServiceWork { name String attributes Json? - status Status @default(CREATED) - statusOrder Int @default(0) - service QuotationService @relation(fields: [serviceId], references: [id]) serviceId String From 0319990232e6b62614bea1458a1e87b49ea80d56 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Fri, 19 Jul 2024 17:59:35 +0700 Subject: [PATCH 05/24] feat: structure input before insert to database --- src/controllers/quotation-controller.ts | 181 +++++++++++++++++++++++- 1 file changed, 175 insertions(+), 6 deletions(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index dfa1554..57f7853 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -26,7 +26,7 @@ type QuotationCreate = { paySplitCount?: number; paySplit?: Date[]; - payBillDate?: number; + payBillDate?: Date; workerCount: number; // EmployeeId or Create new employee @@ -65,12 +65,12 @@ type QuotationCreate = { id: string; // Other fields will come from original data work: { + id: string; // Name field will come from original data product: { id: string; amount: number; discount: number; - pricePerUnit: number; }[]; }[]; }[]; @@ -84,7 +84,7 @@ type QuotationUpdate = { paySplitCount?: number; paySplit?: Date[]; - payBillDate?: number; + payBillDate?: Date; workerCount: number; // EmployeeId or Create new employee @@ -123,12 +123,12 @@ type QuotationUpdate = { id: string; // Other fields will come from original data work: { + id: string; // Name field will come from original data product: { id: string; amount: number; discount: number; - pricePerUnit: number; }[]; }[]; }[]; @@ -197,7 +197,168 @@ export class QuotationController extends Controller { @Post() @Security("keycloak") - async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) {} + 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 (!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", + ); + + const { service: _service, worker: _worker, ...rest } = body; + + await prisma.$transaction(async (tx) => { + const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); + const lastEmployee = await tx.runningNo.upsert({ + where: { + key: `EMPLOYEE_${customerBranch.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().slice(-2).padStart(2, "0")}`, + }, + create: { + key: `EMPLOYEE_${customerBranch.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().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.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().slice(-2).padStart(2, "0")}${(lastEmployee.value + i).toString().padStart(4, "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 }; + 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, + work: a.work.flatMap((c) => { + const currentWork = currentService.work.find((d) => d.id === c.id); + + if (!currentWork) return []; // should not possible + + return { + id: currentWork.id, + order: currentWork.order, + name: currentWork.name, + product: c.product.flatMap((e) => { + const currentProduct = product.find((f) => f.id === e.id); + + if (!currentProduct) return []; // should not possible + + return { ...e, pricePerUnit: currentProduct.price }; + }), + }; + }), + }; + }); + + const quotation = await tx.quotation.create({ + data: { + ...rest, + statusOrder: +(rest.status === "INACTIVE"), + code: "", + worker: { + createMany: { + data: sortedEmployeeId.map((v, i) => ({ + no: i, + code: "", + employeeId: v, + })), + }, + }, + service: { + createMany: { + data: body.service.flatMap((a) => { + const src = service.find((b) => b.id == a.id); + return src + ? { + id: a.id, + name: src.name, + code: src.code, + detail: src.detail, + } + : []; // should not be possible to not found. + }), + }, + }, + totalPrice, + totalDiscount, + vatExcluded: 0, + vat: 0, + paySplit: { + createMany: { + data: (rest.paySplit || []).map((v, i) => ({ + no: i + 1, + date: v, + })), + }, + }, + }, + }); + // await tx.quotationServiceWork.createMany({ + // data: service.flatMap((a) => + // a.work.map((b) => ({ id: b.id, order: b.order, name: b.name, serviceId: a.id })), + // ), + // }); + + throw new Error("Test Quotation Structure"); + }); + } @Put("{quotationId}") @Security("keycloak") @@ -205,7 +366,15 @@ export class QuotationController extends Controller { @Request() req: RequestWithUser, @Path() quotationId: string, @Body() body: QuotationUpdate, - ) {} + ) { + const record = await prisma.quotation.findUnique({ + where: { id: quotationId }, + }); + + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound"); + } + } @Delete("{quotationId}") @Security("keycloak") From d2e7a89211f94dafab5dd9a4d828bd48c750d40a Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Fri, 19 Jul 2024 17:59:44 +0700 Subject: [PATCH 06/24] refactor: structure --- prisma/schema.prisma | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1c4aec5..0fa9294 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -811,8 +811,9 @@ model Quotation { totalPrice Float totalDiscount Float - vat Int - vatExcluded Int + vat Float + vatExcluded Float + finalPrice Float createdAt DateTime @default(now()) createdBy User? @relation(name: "QuotationCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull) @@ -877,6 +878,7 @@ model QuotationServiceWorkProduct { product Product @relation(fields: [productId], references: [id], onDelete: Cascade) productId String + vat Int amount Int discount Float pricePerUnit Float From e509cd1fd5b2c2c1c256afc776ec971b9b600552 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:28:47 +0700 Subject: [PATCH 07/24] feat: create quotation endpoint (test required) --- prisma/schema.prisma | 2 +- src/controllers/quotation-controller.ts | 176 ++++++++++++++++++++---- 2 files changed, 147 insertions(+), 31 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0fa9294..8faedfe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -878,7 +878,7 @@ model QuotationServiceWorkProduct { product Product @relation(fields: [productId], references: [id], onDelete: Cascade) productId String - vat Int + vat Float amount Int discount Float pricePerUnit Float diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index 57f7853..d988a27 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -1,4 +1,4 @@ -import { PayCondition, Status } from "@prisma/client"; +import { PayCondition, Prisma, Status } from "@prisma/client"; import { Body, Controller, @@ -70,7 +70,16 @@ type QuotationCreate = { product: { id: string; amount: number; + /** + * @maximum 1 + * @minimum 0 + */ discount: number; + /** + * @maximum 1 + * @minimum 0 + */ + vat?: number; }[]; }[]; }[]; @@ -127,8 +136,20 @@ type QuotationUpdate = { // 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; }[]; }[]; }[]; @@ -216,9 +237,7 @@ export class QuotationController extends Controller { where: { id: { in: existingEmployee } }, }), prisma.service.findMany({ - include: { - work: true, - }, + include: { work: true }, where: { id: { in: serviceIdList } }, }), prisma.product.findMany({ @@ -226,6 +245,20 @@ export class QuotationController extends Controller { }), ]); + 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 (!customer) throw new HttpError( HttpStatus.BAD_REQUEST, @@ -238,6 +271,12 @@ export class QuotationController extends Controller { "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; @@ -275,7 +314,8 @@ export class QuotationController extends Controller { } } - const price = { totalPrice: 0, totalDiscount }; + const price = { totalPrice: 0, totalDiscount: 0, totalVat: 0 }; + const restructureService = body.service.flatMap((a) => { const currentService = service.find((b) => b.id === a.id); @@ -286,6 +326,7 @@ export class QuotationController extends Controller { name: currentService.name, code: currentService.code, detail: currentService.detail, + attributes: currentService.attributes as Prisma.JsonObject, work: a.work.flatMap((c) => { const currentWork = currentService.work.find((d) => d.id === c.id); @@ -295,12 +336,27 @@ export class QuotationController extends Controller { 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 - return { ...e, pricePerUnit: currentProduct.price }; + price.totalPrice += currentProduct.price; + price.totalDiscount += Math.round(currentProduct.price * e.discount * 100) / 100; + price.totalVat += + Math.round( + (currentProduct.price - currentProduct.price * e.discount) * + (e.vat === undefined ? 0.07 : e.vat) * + 100, + ) / 100; + + console.log(e.vat); + return { + ...e, + vat: e.vat === undefined ? 0.07 : e.vat, + pricePerUnit: currentProduct.price, + }; }), }; }), @@ -308,6 +364,9 @@ export class QuotationController extends Controller { }); const quotation = await tx.quotation.create({ + include: { + service: true, + }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), @@ -321,25 +380,14 @@ export class QuotationController extends Controller { })), }, }, - service: { - createMany: { - data: body.service.flatMap((a) => { - const src = service.find((b) => b.id == a.id); - return src - ? { - id: a.id, - name: src.name, - code: src.code, - detail: src.detail, - } - : []; // should not be possible to not found. - }), - }, - }, - totalPrice, - totalDiscount, + totalPrice: price.totalPrice, + totalDiscount: price.totalDiscount, + + vat: price.totalVat, vatExcluded: 0, - vat: 0, + + finalPrice: price.totalPrice - price.totalDiscount, + paySplit: { createMany: { data: (rest.paySplit || []).map((v, i) => ({ @@ -348,15 +396,83 @@ export class QuotationController extends Controller { })), }, }, + + createdByUserId: req.user.sub, + updatedByUserId: req.user.sub, }, }); - // await tx.quotationServiceWork.createMany({ - // data: service.flatMap((a) => - // a.work.map((b) => ({ id: b.id, order: b.order, name: b.name, serviceId: a.id })), - // ), - // }); - throw new Error("Test Quotation Structure"); + await Promise.all( + restructureService.map(async (a) => { + await tx.quotationService.create({ + data: { + id: a.id, + code: a.code, + name: a.name, + detail: a.detail, + attributes: a.attributes, + quotationId: quotation.id, + }, + }); + + await Promise.all( + a.work.map(async (b) => { + await tx.quotationServiceWork.create({ + data: { + id: b.id, + order: b.order, + name: b.name, + attributes: b.attributes, + serviceId: a.id, + 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, + })), + }, + }, + }, + }); + }), + ); + }), + ); + + const result = await tx.quotation.findUnique({ + include: { + service: { + include: { + work: { + include: { + productOnWork: { + include: { product: true }, + }, + }, + }, + }, + }, + paySplit: true, + worker: true, + customerBranch: { + include: { customer: true }, + }, + _count: { + select: { service: true }, + }, + }, + where: { id: quotation.id }, + }); + + console.log(JSON.stringify(result, null, 2)); + + // console.log("Re-Structure:", JSON.stringify(restructureService, null, 2)); + // console.log("Price:", JSON.stringify(price, null, 2)); + throw new Error(""); }); } From 7d7ac384c3e318beb4df60bdde6675b314d1c672 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 23 Jul 2024 11:23:12 +0700 Subject: [PATCH 08/24] fix: error fk on empty payload registeredBranch field --- src/controllers/customer-controller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/controllers/customer-controller.ts b/src/controllers/customer-controller.ts index 06e2f20..ca10da2 100644 --- a/src/controllers/customer-controller.ts +++ b/src/controllers/customer-controller.ts @@ -296,13 +296,16 @@ export class CustomerController extends Controller { "relationSubDistrictNotFound", ); } - if (body.registeredBranchId && !branch) { + if (!!body.registeredBranchId && !branch) { throw new HttpError( HttpStatus.BAD_REQUEST, "Branch cannot be found.", "relationBranchNotFound", ); } + if (!body.registeredBranchId) { + body.registeredBranchId = undefined; + } const record = await prisma.$transaction( async (tx) => { From 0bcb69b09f8d40304ef8eed256107e689155e36d Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 23 Jul 2024 11:24:07 +0700 Subject: [PATCH 09/24] feat: add quotation endpoint (complete) --- src/controllers/quotation-controller.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index d988a27..68d5dbb 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -259,6 +259,13 @@ export class QuotationController extends Controller { "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, @@ -280,7 +287,7 @@ export class QuotationController extends Controller { const { service: _service, worker: _worker, ...rest } = body; - await prisma.$transaction(async (tx) => { + return await prisma.$transaction(async (tx) => { const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); const lastEmployee = await tx.runningNo.upsert({ where: { @@ -351,7 +358,6 @@ export class QuotationController extends Controller { 100, ) / 100; - console.log(e.vat); return { ...e, vat: e.vat === undefined ? 0.07 : e.vat, @@ -404,9 +410,8 @@ export class QuotationController extends Controller { await Promise.all( restructureService.map(async (a) => { - await tx.quotationService.create({ + const { id: _currentServiceId } = await tx.quotationService.create({ data: { - id: a.id, code: a.code, name: a.name, detail: a.detail, @@ -419,11 +424,10 @@ export class QuotationController extends Controller { a.work.map(async (b) => { await tx.quotationServiceWork.create({ data: { - id: b.id, order: b.order, name: b.name, attributes: b.attributes, - serviceId: a.id, + serviceId: _currentServiceId, productOnWork: { createMany: { data: b.product.map((v, i) => ({ @@ -443,7 +447,7 @@ export class QuotationController extends Controller { }), ); - const result = await tx.quotation.findUnique({ + return await tx.quotation.findUnique({ include: { service: { include: { @@ -467,12 +471,6 @@ export class QuotationController extends Controller { }, where: { id: quotation.id }, }); - - console.log(JSON.stringify(result, null, 2)); - - // console.log("Re-Structure:", JSON.stringify(restructureService, null, 2)); - // console.log("Price:", JSON.stringify(price, null, 2)); - throw new Error(""); }); } From e397f467912aafed25cc0e405fd6de28898b52af Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 23 Jul 2024 11:24:20 +0700 Subject: [PATCH 10/24] fix: error on disable log --- src/middlewares/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index 37038d7..694fd4f 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -11,6 +11,7 @@ export async function expressAuthentication( switch (securityName) { case "keycloak": const authData = await keycloakAuth(request, scopes); + if (!request.app.locals.logData) request.app.locals.logData = {}; request.app.locals.logData.sessionId = authData.session_state; request.app.locals.logData.user = authData.preferred_username; request.app.locals.logData.userName = authData.name; From e233869efe35c1f584892c91f1216d1f99aacf40 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 23 Jul 2024 11:24:32 +0700 Subject: [PATCH 11/24] chore: remove log --- src/middlewares/log.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/middlewares/log.ts b/src/middlewares/log.ts index 992e1d4..1b13ea3 100644 --- a/src/middlewares/log.ts +++ b/src/middlewares/log.ts @@ -61,8 +61,6 @@ async function logMiddleware(req: Request, res: Response, next: NextFunction) { ...req.app.locals.logData, }; - console.log(obj); - elasticsearch.index({ index: ELASTICSEARCH_INDEX, document: obj, From bada98d12d055a7a1e0c6582818cedd34457ced1 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 23 Jul 2024 11:53:43 +0700 Subject: [PATCH 12/24] feat: exclude some work from service --- src/controllers/quotation-controller.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index 68d5dbb..02e4347 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -67,6 +67,7 @@ type QuotationCreate = { work: { id: string; // Name field will come from original data + excluded?: boolean; product: { id: string; amount: number; @@ -335,6 +336,8 @@ export class QuotationController extends Controller { 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 []; // should not possible From fbf7f6c968665df1e72a05d6127cb14dd80bf547 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:43:06 +0700 Subject: [PATCH 13/24] feat: add original service reference --- .../20240723084219_add_service_ref/migration.sql | 11 +++++++++++ prisma/schema.prisma | 7 ++++++- src/controllers/quotation-controller.ts | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20240723084219_add_service_ref/migration.sql diff --git a/prisma/migrations/20240723084219_add_service_ref/migration.sql b/prisma/migrations/20240723084219_add_service_ref/migration.sql new file mode 100644 index 0000000..7505e27 --- /dev/null +++ b/prisma/migrations/20240723084219_add_service_ref/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `refServiceId` to the `QuotationService` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "QuotationService" ADD COLUMN "refServiceId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "QuotationService" ADD CONSTRAINT "QuotationService_refServiceId_fkey" FOREIGN KEY ("refServiceId") REFERENCES "Service"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8faedfe..328b6b6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -718,7 +718,8 @@ model Service { status Status @default(CREATED) statusOrder Int @default(0) - work Work[] + work Work[] + quotationService QuotationService[] productType ProductType? @relation(fields: [productTypeId], references: [id], onDelete: SetNull) productTypeId String? @@ -854,8 +855,12 @@ model QuotationService { work QuotationServiceWork[] + refServiceId String + refService Service @relation(fields: [refServiceId], references: [id]) + quotation Quotation @relation(fields: [quotationId], references: [id]) quotationId String + serviceId String } model QuotationServiceWork { diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index 02e4347..f586168 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -420,6 +420,7 @@ export class QuotationController extends Controller { detail: a.detail, attributes: a.attributes, quotationId: quotation.id, + refServiceId: a.id, }, }); From c13eeb9b1459e759c23039340f132f304ccf6905 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 23 Jul 2024 16:14:39 +0700 Subject: [PATCH 14/24] feat: delete quotation endpoints --- src/controllers/quotation-controller.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index f586168..628d259 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -496,5 +496,25 @@ export class QuotationController extends Controller { @Delete("{quotationId}") @Security("keycloak") - async deleteQuotationById(@Request() req: RequestWithUser, @Path() quotationId: string) {} + async deleteQuotationById(@Request() req: RequestWithUser, @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 }, + }); + } } From 67c3ead7ec3003dc6088ec250730e1d76e584cc0 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:26:17 +0700 Subject: [PATCH 15/24] fix: calculation --- src/controllers/quotation-controller.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index 628d259..90f8a11 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -340,7 +340,7 @@ export class QuotationController extends Controller { const currentWork = currentService.work.find((d) => d.id === c.id); - if (!currentWork) return []; // should not possible + if (!currentWork) return []; // additional will get stripped return { id: currentWork.id, @@ -352,11 +352,13 @@ export class QuotationController extends Controller { if (!currentProduct) return []; // should not possible - price.totalPrice += currentProduct.price; - price.totalDiscount += Math.round(currentProduct.price * e.discount * 100) / 100; + price.totalPrice += currentProduct.price * e.amount; + price.totalDiscount += + Math.round(currentProduct.price * e.amount * e.discount * 100) / 100; price.totalVat += Math.round( - (currentProduct.price - currentProduct.price * e.discount) * + (currentProduct.price * e.amount - + currentProduct.price * e.amount * e.discount) * (e.vat === undefined ? 0.07 : e.vat) * 100, ) / 100; @@ -373,9 +375,6 @@ export class QuotationController extends Controller { }); const quotation = await tx.quotation.create({ - include: { - service: true, - }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), From 7d96d1afb6151bcd29af27c8067005ef33705661 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Wed, 24 Jul 2024 13:33:46 +0700 Subject: [PATCH 16/24] feat: update quotation --- src/controllers/quotation-controller.ts | 292 +++++++++++++++++++++++- 1 file changed, 288 insertions(+), 4 deletions(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index 90f8a11..ec1893d 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -89,7 +89,7 @@ type QuotationCreate = { type QuotationUpdate = { status?: "ACTIVE" | "INACTIVE"; - payCondition: PayCondition; + payCondition?: PayCondition; paySplitCount?: number; paySplit?: Date[]; @@ -124,16 +124,17 @@ type QuotationUpdate = { } )[]; - customerBranchId: string; - customerId: string; + customerBranchId?: string; + customerId?: string; urgent?: boolean; - service: { + 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; @@ -485,12 +486,295 @@ export class QuotationController extends Controller { @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.findUnique({ + where: { id: body.customerId }, + }), + tx.customerBranch.findUnique({ + 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.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().slice(-2).padStart(2, "0")}`, + }, + create: { + key: `EMPLOYEE_${customerBranch.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().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.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().slice(-2).padStart(2, "0")}${(lastEmployee.value + i).toString().padStart(4, "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; + price.totalDiscount += Math.round(currentProduct.price * e.discount * 100) / 100; + price.totalVat += + Math.round( + (currentProduct.price - currentProduct.price * 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 quotation = await tx.quotation.update({ + include: { + worker: { + include: { employee: 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 ? { deleteMany: {} } : undefined, + + updatedByUserId: req.user.sub, + }, + }); + + if (restructureService) + await Promise.all( + restructureService.map(async (a) => { + const { id: _currentServiceId } = await tx.quotationService.create({ + data: { + code: a.code, + name: a.name, + detail: a.detail, + attributes: a.attributes, + quotationId: quotation.id, + refServiceId: a.id, + }, + }); + + await Promise.all( + a.work.map(async (b) => { + await tx.quotationServiceWork.create({ + data: { + order: b.order, + name: b.name, + attributes: b.attributes, + serviceId: _currentServiceId, + 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, + })), + }, + }, + }, + }); + }), + ); + }), + ); + + return await tx.quotation.findUnique({ + include: { + service: { + include: { + work: { + include: { + productOnWork: { + include: { product: true }, + }, + }, + }, + }, + }, + paySplit: true, + worker: true, + customerBranch: { + include: { customer: true }, + }, + _count: { + select: { service: true }, + }, + }, + where: { id: quotation.id }, + }); + }); } @Delete("{quotationId}") From 99e2001b34d35344fee561add56aae4e97019472 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Wed, 24 Jul 2024 13:36:47 +0700 Subject: [PATCH 17/24] fix: wrong type --- prisma/schema.prisma | 1 - 1 file changed, 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 328b6b6..66883c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -860,7 +860,6 @@ model QuotationService { quotation Quotation @relation(fields: [quotationId], references: [id]) quotationId String - serviceId String } model QuotationServiceWork { From 8f5f4822f94f5d094a6dc5b28b9db51afc769756 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Wed, 24 Jul 2024 13:44:19 +0700 Subject: [PATCH 18/24] chore: remove unnecessary --- src/controllers/quotation-controller.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index ec1893d..bf3a88a 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -659,11 +659,6 @@ export class QuotationController extends Controller { }); const quotation = await tx.quotation.update({ - include: { - worker: { - include: { employee: true }, - }, - }, where: { id: quotationId }, data: { ...rest, From 126a44cb7f3d3a90a1fe922d4f075fe4d10acff6 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:19:45 +0700 Subject: [PATCH 19/24] refactor: update constraints --- .../migration.sql | 17 +++++++++++++++++ prisma/schema.prisma | 6 +++--- 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20240724071058_update_constraints/migration.sql diff --git a/prisma/migrations/20240724071058_update_constraints/migration.sql b/prisma/migrations/20240724071058_update_constraints/migration.sql new file mode 100644 index 0000000..28794fa --- /dev/null +++ b/prisma/migrations/20240724071058_update_constraints/migration.sql @@ -0,0 +1,17 @@ +-- DropForeignKey +ALTER TABLE "QuotationService" DROP CONSTRAINT "QuotationService_quotationId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotationServiceWork" DROP CONSTRAINT "QuotationServiceWork_serviceId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotationServiceWorkProduct" DROP CONSTRAINT "QuotationServiceWorkProduct_workId_fkey"; + +-- AddForeignKey +ALTER TABLE "QuotationService" ADD CONSTRAINT "QuotationService_quotationId_fkey" FOREIGN KEY ("quotationId") REFERENCES "Quotation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuotationServiceWork" ADD CONSTRAINT "QuotationServiceWork_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "QuotationService"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuotationServiceWorkProduct" ADD CONSTRAINT "QuotationServiceWorkProduct_workId_fkey" FOREIGN KEY ("workId") REFERENCES "QuotationServiceWork"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 66883c9..afab626 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -858,7 +858,7 @@ model QuotationService { refServiceId String refService Service @relation(fields: [refServiceId], references: [id]) - quotation Quotation @relation(fields: [quotationId], references: [id]) + quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade) quotationId String } @@ -869,7 +869,7 @@ model QuotationServiceWork { name String attributes Json? - service QuotationService @relation(fields: [serviceId], references: [id]) + service QuotationService @relation(fields: [serviceId], references: [id], onDelete: Cascade) serviceId String productOnWork QuotationServiceWorkProduct[] @@ -877,7 +877,7 @@ model QuotationServiceWork { model QuotationServiceWorkProduct { order Int - work QuotationServiceWork @relation(fields: [workId], references: [id]) + work QuotationServiceWork @relation(fields: [workId], references: [id], onDelete: Cascade) workId String product Product @relation(fields: [productId], references: [id], onDelete: Cascade) productId String From 2c99b92aa5ca1308d2e483d5f1dbec4f5872c32f Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:26:06 +0700 Subject: [PATCH 20/24] fix: error and price calc --- src/controllers/quotation-controller.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index bf3a88a..6e77fa9 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -96,7 +96,7 @@ type QuotationUpdate = { payBillDate?: Date; - workerCount: number; + workerCount?: number; // EmployeeId or Create new employee worker?: ( | string @@ -503,10 +503,10 @@ export class QuotationController extends Controller { const [customer, customerBranch, employee, service, product] = await prisma.$transaction( async (tx) => await Promise.all([ - tx.customer.findUnique({ + tx.customer.findFirst({ where: { id: body.customerId }, }), - tx.customerBranch.findUnique({ + tx.customerBranch.findFirst({ include: { customer: true }, where: { id: body.customerBranchId }, }), @@ -638,11 +638,13 @@ export class QuotationController extends Controller { if (!currentProduct) return []; // should not possible - price.totalPrice += currentProduct.price; - price.totalDiscount += Math.round(currentProduct.price * e.discount * 100) / 100; + price.totalPrice += currentProduct.price * e.amount; + price.totalDiscount += + Math.round(currentProduct.price * e.amount * e.discount * 100) / 100; price.totalVat += Math.round( - (currentProduct.price - currentProduct.price * e.discount) * + (currentProduct.price * e.amount - + currentProduct.price * e.amount * e.discount) * (e.vat === undefined ? 0.07 : e.vat) * 100, ) / 100; @@ -704,7 +706,7 @@ export class QuotationController extends Controller { }, }); - if (restructureService) + if (restructureService) { await Promise.all( restructureService.map(async (a) => { const { id: _currentServiceId } = await tx.quotationService.create({ @@ -744,6 +746,7 @@ export class QuotationController extends Controller { ); }), ); + } return await tx.quotation.findUnique({ include: { From c97a8e5f6630b516f7bc274121b920842f5d4bbe Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:26:23 +0700 Subject: [PATCH 21/24] feaet: protect by roles --- src/controllers/quotation-controller.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index 6e77fa9..c572660 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -157,6 +157,22 @@ type QuotationUpdate = { }[]; }; +const MANAGE_ROLES = [ + "system", + "head_of_admin", + "admin", + "branch_admin", + "branch_manager", + "accountant", + "branch_accountant", +]; + +function globalAllow(roles?: string[]) { + return ["system", "head_of_admin", "admin", "branch_admin", "branch_manager", "accountant"].some( + (v) => roles?.includes(v), + ); +} + @Route("/api/v1/quotation") @Tags("Quotation") export class QuotationController extends Controller { @@ -219,7 +235,7 @@ export class QuotationController extends Controller { } @Post() - @Security("keycloak") + @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); @@ -479,7 +495,7 @@ export class QuotationController extends Controller { } @Put("{quotationId}") - @Security("keycloak") + @Security("keycloak", MANAGE_ROLES) async editQuotation( @Request() req: RequestWithUser, @Path() quotationId: string, @@ -776,7 +792,7 @@ export class QuotationController extends Controller { } @Delete("{quotationId}") - @Security("keycloak") + @Security("keycloak", MANAGE_ROLES) async deleteQuotationById(@Request() req: RequestWithUser, @Path() quotationId: string) { const record = await prisma.quotation.findUnique({ where: { id: quotationId }, From 557254eb1996fee9d858427d435d577e0ec8ed32 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:42:21 +0700 Subject: [PATCH 22/24] refactor: reorder endpoints (affect swagger) --- src/controllers/quotation-controller.ts | 56 ++++++++++++------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index c572660..6bbbdc3 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -176,6 +176,34 @@ function globalAllow(roles?: string[]) { @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) { @@ -206,34 +234,6 @@ export class QuotationController extends Controller { return record; } - @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 }; - } - @Post() @Security("keycloak", MANAGE_ROLES) async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) { From c29ec941ca637b625901b4e243e734e3f540536d Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Thu, 25 Jul 2024 11:44:01 +0700 Subject: [PATCH 23/24] chore: remove unused --- src/controllers/quotation-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts index 6bbbdc3..3679852 100644 --- a/src/controllers/quotation-controller.ts +++ b/src/controllers/quotation-controller.ts @@ -793,7 +793,7 @@ export class QuotationController extends Controller { @Delete("{quotationId}") @Security("keycloak", MANAGE_ROLES) - async deleteQuotationById(@Request() req: RequestWithUser, @Path() quotationId: string) { + async deleteQuotationById(@Path() quotationId: string) { const record = await prisma.quotation.findUnique({ where: { id: quotationId }, }); From 89b5a22b75439efb013b8db6c91bd1257e4a4966 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Fri, 26 Jul 2024 17:35:09 +0700 Subject: [PATCH 24/24] refactor: swap logger --- src/app.ts | 12 +++++-- src/middlewares/log.ts | 73 --------------------------------------- src/middlewares/logger.ts | 40 +++++++++++++++++++++ src/middlewares/morgan.ts | 69 ++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 76 deletions(-) delete mode 100644 src/middlewares/log.ts create mode 100644 src/middlewares/logger.ts create mode 100644 src/middlewares/morgan.ts diff --git a/src/app.ts b/src/app.ts index b933540..f942a08 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,8 +4,8 @@ import express, { json, urlencoded } from "express"; import swaggerUi from "swagger-ui-express"; import swaggerDocument from "./swagger.json"; import error from "./middlewares/error"; +import morgan from "./middlewares/morgan"; import { RegisterRoutes } from "./routes"; -import logMiddleware from "./middlewares/log"; import { addUserRoles, createUser, getRoleByName, listUser } from "./services/keycloak"; import prisma from "./db"; @@ -62,11 +62,17 @@ const APP_PORT = +(process.env.APP_PORT || 3000); }); } + const originalSend = app.response.json; + + app.response.json = function (body: unknown) { + this.app.locals.response = body; + return originalSend.call(this, body); + }; + app.use(cors()); app.use(json()); app.use(urlencoded({ extended: true })); - - app.use(logMiddleware); + app.use(morgan); app.use("/", express.static("static")); app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); diff --git a/src/middlewares/log.ts b/src/middlewares/log.ts deleted file mode 100644 index 1b13ea3..0000000 --- a/src/middlewares/log.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import elasticsearch from "../services/elasticsearch"; -import { randomUUID } from "crypto"; - -if (!process.env.ELASTICSEARCH_INDEX) { - throw new Error("Require ELASTICSEARCH_INDEX to store log."); -} - -const ELASTICSEARCH_INDEX = process.env.ELASTICSEARCH_INDEX; - -const LOG_LEVEL_MAP: Record = { - debug: 4, - info: 3, - warning: 2, - error: 1, - none: 0, -}; - -async function logMiddleware(req: Request, res: Response, next: NextFunction) { - if (!req.url.startsWith("/api/")) return next(); - - let data: any; - - const originalJson = res.json; - - res.json = function (v: any) { - data = v; - return originalJson.call(this, v); - }; - - const timestamp = new Date().toISOString(); - const start = performance.now(); - - req.app.locals.logData = {}; - - res.on("finish", () => { - if (!req.url.startsWith("/api/")) return; - - const level = LOG_LEVEL_MAP[process.env.LOG_LEVEL ?? "info"] || 1; - - if (level === 1 && res.statusCode < 500) return; - if (level === 2 && res.statusCode < 400) return; - if (level === 3 && res.statusCode < 200) return; - - const obj = { - logType: res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warning" : "info", - systemName: "JWS-SOS", - startTimeStamp: timestamp, - endTimeStamp: new Date().toISOString(), - processTime: performance.now() - start, - host: req.hostname, - sessionId: req.headers["x-session-id"], - rtId: req.headers["x-rtid"], - tId: randomUUID(), - method: req.method, - endpoint: req.url, - responseCode: res.statusCode, - responseDescription: data?.code, - input: (level === 4 && JSON.stringify(req.body, null, 2)) || undefined, - output: (level === 4 && JSON.stringify(data, null, 2)) || undefined, - ...req.app.locals.logData, - }; - - elasticsearch.index({ - index: ELASTICSEARCH_INDEX, - document: obj, - }); - }); - - return next(); -} - -export default logMiddleware; diff --git a/src/middlewares/logger.ts b/src/middlewares/logger.ts new file mode 100644 index 0000000..a72a9eb --- /dev/null +++ b/src/middlewares/logger.ts @@ -0,0 +1,40 @@ +import winston from "winston"; +import { ElasticsearchTransport } from "winston-elasticsearch"; +import elasticsearch from "../services/elasticsearch"; + +const logger = winston.createLogger({ + levels: winston.config.syslog.levels, + defaultMeta: { serviceName: "jws-sos" }, + transports: [ + new ElasticsearchTransport({ + level: "info", + index: "app-log-test-winston-index", + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), + client: elasticsearch, + transformer: (payload) => { + const { logData: additional, ...rest } = payload.meta; + return { + level: payload.level, + ...rest, + ...additional, + requestBody: + process.env.LOG_LEVEL === "debug" ? JSON.stringify(rest.requestBody) : undefined, + responseBody: + process.env.LOG_LEVEL === "debug" ? JSON.stringify(rest.responseBody) : undefined, + }; + }, + }), + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.timestamp(), + winston.format.printf( + ({ level, timestamp, logData, responseBody, requestBody, ...payload }) => + `${level} ${timestamp} ${JSON.stringify(Object.assign(payload, logData), null, 4)}`, + ), + ), + }), + ], +}); + +export default logger; diff --git a/src/middlewares/morgan.ts b/src/middlewares/morgan.ts new file mode 100644 index 0000000..cca1096 --- /dev/null +++ b/src/middlewares/morgan.ts @@ -0,0 +1,69 @@ +import express from "express"; +import morgan from "morgan"; +import logger from "./logger"; +import { randomUUID } from "crypto"; + +const LOG_LEVEL_MAP: Record = { + debug: 4, + info: 3, + warning: 2, + error: 1, + none: 0, +}; + +// log the HTTP method, request URL, response status, and response time. +const logFormat = `{ + "requestMethod": ":method", + "requestUrl": ":url", + "responseStatus": ":status", + "responseTime": ":response-time ms", + "transactionId": ":transaction-id", + "refTransactionId": ":ref-transaction-id", + "sessionId": ":session-id", + "requestBody": :request-body, + "responseBody": :response-body, + "logData": :log-data +}`; + +function logMessageHandler(message: string) { + const data = JSON.parse(message.trim()); + + const level = LOG_LEVEL_MAP[process.env.LOG_LEVEL ?? "info"] || 1; + const status = +data.responseStatus; + + if (level === 1 && status < 500) return; + if (level === 2 && status < 400) return; + if (level === 3 && status < 200) return; + + if (status >= 500) return logger.error("HTTP request received", JSON.parse(message.trim())); + if (status >= 400) return logger.warning("HTTP request received", JSON.parse(message.trim())); + return logger.info("HTTP request received", JSON.parse(message.trim())); +} + +morgan.token("log-data", (req: express.Request) => { + return JSON.stringify(req.app.locals.logData || {}); +}); +morgan.token("request-body", (req: express.Request) => { + return JSON.stringify(req.body); +}); +morgan.token("response-body", (req: express.Request) => { + return JSON.stringify(req.app.locals.response || {}); +}); +morgan.token("identity-field", (req: express.Request) => { + return req.app.locals.identityField; +}); +morgan.token("session-id", (req: express.Request) => { + return req.headers["x-session-id"] as string | undefined; +}); +morgan.token("ref-transaction-id", (req: express.Request) => { + return req.headers["x-rtid"] as string | undefined; +}); +morgan.token("transaction-id", () => { + return randomUUID(); +}); + +const loggingMiddleware = morgan(logFormat, { + stream: { write: logMessageHandler }, +}); + +export default loggingMiddleware;