Merge branch 'feat/quotation-endpoints' into dev
This commit is contained in:
commit
f88e149318
10 changed files with 1095 additions and 84 deletions
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
12
src/app.ts
12
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));
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
817
src/controllers/quotation-controller.ts
Normal file
817
src/controllers/quotation-controller.ts
Normal file
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, number> = {
|
||||
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;
|
||||
40
src/middlewares/logger.ts
Normal file
40
src/middlewares/logger.ts
Normal file
|
|
@ -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;
|
||||
69
src/middlewares/morgan.ts
Normal file
69
src/middlewares/morgan.ts
Normal file
|
|
@ -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<string, number> = {
|
||||
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue