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/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 6431837..afab626 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) @@ -712,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? @@ -767,3 +774,118 @@ 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]) + + status Status @default(CREATED) + statusOrder Int @default(0) + + 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 Float + vatExcluded Float + finalPrice Float + + 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? + + work QuotationServiceWork[] + + refServiceId String + refService Service @relation(fields: [refServiceId], references: [id]) + + quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade) + quotationId String +} + +model QuotationServiceWork { + id String @id @default(uuid()) + + 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 + + vat Float + amount Int + discount Float + pricePerUnit Float + + @@id([workId, productId]) +} 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/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) => { diff --git a/src/controllers/quotation-controller.ts b/src/controllers/quotation-controller.ts new file mode 100644 index 0000000..3679852 --- /dev/null +++ b/src/controllers/quotation-controller.ts @@ -0,0 +1,817 @@ +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_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 { + @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.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: 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 quotation = await tx.quotation.create({ + data: { + ...rest, + statusOrder: +(rest.status === "INACTIVE"), + code: "", + 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, + })), + }, + }, + + createdByUserId: req.user.sub, + updatedByUserId: req.user.sub, + }, + }); + + 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 }, + }); + }); + } + + @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.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 * 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 quotation = await tx.quotation.update({ + 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}") + @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 }, + }); + } +} 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; diff --git a/src/middlewares/log.ts b/src/middlewares/log.ts deleted file mode 100644 index 992e1d4..0000000 --- a/src/middlewares/log.ts +++ /dev/null @@ -1,75 +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, - }; - - console.log(obj); - - 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;