Merge branch 'feat/quotation-endpoints' into dev

This commit is contained in:
Methapon2001 2024-07-26 17:57:50 +07:00
commit f88e149318
10 changed files with 1095 additions and 84 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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])
}

View file

@ -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));

View file

@ -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) => {

View 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 },
});
}
}

View file

@ -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;

View file

@ -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
View 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
View 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;