jws-backend/src/controllers/05-quotation-controller.ts
2024-10-03 14:09:34 +07:00

643 lines
18 KiB
TypeScript

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 {
branchRelationPermInclude,
createPermCheck,
createPermCondition,
} from "../services/permission";
import { isSystem } from "../utils/keycloak";
import { isUsedError, notFoundError, relationError } from "../utils/error";
type QuotationCreate = {
status?: Status;
actorName: string;
workName: string;
contactName: string;
contactTel: string;
documentReceivePoint: string;
dueDate: Date;
payCondition: PayCondition;
paySplitCount?: number;
paySplit?: { date: Date; amount: number }[];
payBillDate?: Date;
workerCount: number;
// EmployeeId or Create new employee
worker: (
| string
| {
dateOfBirth: Date;
gender: string;
nationality: string;
namePrefix?: string;
firstName: string;
firstNameEN: string;
middleName?: string;
middleNameEN?: string;
lastName: string;
lastNameEN: string;
}
)[];
customerBranchId: string;
urgent?: boolean;
productServiceList: {
serviceId?: string;
workId?: string;
productId: string;
amount: number;
/**
* @maximum 1
* @minimum 0
*/
discount?: number;
pricePerUnit?: number;
/**
* @maximum 1
* @minimum 0
*/
vat?: number;
}[];
};
type QuotationUpdate = {
status?: "ACTIVE" | "INACTIVE";
actorName?: string;
workName?: string;
contactName?: string;
contactTel?: string;
documentReceivePoint?: string;
dueDate?: Date;
payCondition?: PayCondition;
paySplitCount?: number;
paySplit?: { date: Date; amount: number }[];
payBillDate?: Date;
workerCount?: number;
// EmployeeId or Create new employee
worker?: (
| string
| {
dateOfBirth: Date;
gender: string;
nationality: string;
namePrefix?: string;
firstName: string;
firstNameEN: string;
middleName?: string;
middleNameEN?: string;
lastName: string;
lastNameEN: string;
}
)[];
customerBranchId?: string;
urgent?: boolean;
productServiceList?: {
serviceId?: string;
workId?: string;
productId: string;
amount: number;
/**
* @maximum 1
* @minimum 0
*/
discount: number;
pricePerUnit?: number;
/**
* @maximum 1
* @minimum 0
*/
vat?: number;
}[];
};
const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"head_of_account",
"account",
"head_of_sale",
"sale",
];
const VAT_DEFAULT = 0.07;
function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_account", "head_of_sale"];
return allowList.some((v) => user.roles?.includes(v));
}
const permissionCheck = createPermCheck(globalAllow);
const permissionCond = createPermCondition(globalAllow);
@Route("/api/v1/quotation")
@Tags("Quotation")
export class QuotationController extends Controller {
@Get()
@Security("keycloak")
async getQuotationList(
@Request() req: RequestWithUser,
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() payCondition?: PayCondition,
) {
const where = {
payCondition,
customerBranch: {
customer: {
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
},
},
} satisfies Prisma.QuotationWhereInput;
const [result, total] = await prisma.$transaction([
prisma.quotation.findMany({
where,
include: {
customerBranch: true,
worker: true,
productServiceList: {
include: {
product: true,
work: true,
service: true,
},
},
paySplit: true,
},
}),
prisma.quotation.count({ where }),
]);
return { result: result, page, pageSize, total };
}
@Get("{quotationId}")
@Security("keycloak")
async getQuotationById(@Path() quotationId: string) {
const record = await prisma.quotation.findUnique({
include: {
_count: {
select: { worker: true },
},
worker: {
include: { employee: true },
},
productServiceList: {
include: {
product: true,
work: true,
service: true,
},
},
paySplit: true,
},
where: { id: quotationId },
});
if (!record) throw notFoundError("Quotation");
return record;
}
@Post()
@Security("keycloak", MANAGE_ROLES)
async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) {
const ids = {
employee: body.worker.filter((v) => typeof v === "string"),
product: body.productServiceList
.map((v) => v.productId)
.filter((v, i, a) => a.findIndex((c) => c === v) === i),
work: body.productServiceList.map((v) => v.workId || []).flat(),
service: body.productServiceList.map((v) => v.serviceId || []).flat(),
};
const [customerBranch, employee, product, work, service] = await prisma.$transaction(
async (tx) =>
await Promise.all([
tx.customerBranch.findUnique({
include: {
customer: {
include: {
registeredBranch: {
include: branchRelationPermInclude(req.user),
},
},
},
},
where: { id: body.customerBranchId },
}),
tx.employee.findMany({ where: { id: { in: ids.employee } } }),
tx.product.findMany({ where: { id: { in: ids.product } } }),
ids.work.length ? tx.work.findMany({ where: { id: { in: ids.work } } }) : null,
ids.service.length ? tx.service.findMany({ where: { id: { in: ids.service } } }) : null,
]),
);
if (!customerBranch) throw relationError("Customer Branch");
if (ids.employee.length !== employee.length) throw relationError("Worker");
if (ids.product.length !== product.length) throw relationError("Product");
if (ids.work.length && ids.work.length !== work?.length) throw relationError("Work");
if (ids.service.length && ids.service.length !== service?.length) {
throw relationError("Service");
}
await permissionCheck(req.user, customerBranch.customer.registeredBranch);
const { productServiceList: _productServiceList, 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.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
},
create: {
key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
value: nonExistEmployee.length,
},
update: { value: { increment: nonExistEmployee.length } },
});
const newEmployee = await Promise.all(
nonExistEmployee.map(async (v, i) =>
tx.employee.create({
data: {
...v,
code: `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "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 currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const currentDate = new Date().getDate();
const lastQuotation = await tx.runningNo.upsert({
where: {
key: `QUOTATION_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${currentDate.toString().padStart(2, "0")}`,
},
create: {
key: `QUOTATION_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${currentDate.toString().padStart(2, "0")}`,
value: 1,
},
update: { value: { increment: 1 } },
});
const list = body.productServiceList.map((v, i) => ({
order: i + 1,
productId: v.productId,
workId: v.workId,
serviceId: v.serviceId,
pricePerUnit: v.pricePerUnit || product.find((p) => p.id === v.productId)?.price || 0,
amount: v.amount,
discount: v.discount || 0,
vat: v.vat || VAT_DEFAULT,
}));
const price = list.reduce(
(a, c) => {
const price = c.pricePerUnit * c.amount;
const discount = Math.round(price * (c.discount || 0) * 100) / 100;
const vat = Math.round((price - discount) * c.vat * 100) / 100;
a.totalPrice += price;
a.totalDiscount += discount;
a.vat += vat;
a.finalPrice += price - discount + vat;
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
finalPrice: 0,
},
);
return await tx.quotation.create({
include: {
productServiceList: {
include: {
product: true,
work: true,
service: true,
},
},
paySplit: true,
worker: true,
customerBranch: {
include: { customer: true },
},
_count: {
select: { productServiceList: true },
},
},
data: {
...rest,
...price,
statusOrder: +(rest.status === "INACTIVE"),
code: `${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${currentDate.toString().padStart(2, "0")}${lastQuotation.value.toString().padStart(4, "0")}`,
worker: {
createMany: {
data: sortedEmployeeId.map((v, i) => ({
no: i,
code: "",
employeeId: v,
})),
},
},
paySplit: {
createMany: {
data: (rest.paySplit || []).map((v, i) => ({
no: i + 1,
...v,
})),
},
},
productServiceList: { create: list },
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
});
}
@Put("{quotationId}")
@Security("keycloak", MANAGE_ROLES)
async editQuotation(
@Request() req: RequestWithUser,
@Path() quotationId: string,
@Body() body: QuotationUpdate,
) {
const record = await prisma.quotation.findUnique({
include: {
customerBranch: {
include: {
customer: {
include: {
registeredBranch: { include: branchRelationPermInclude(req.user) },
},
},
},
},
},
where: { id: quotationId },
});
if (!record) throw notFoundError("Quotation");
const ids = {
employee: body.worker?.filter((v) => typeof v === "string"),
product: body.productServiceList
?.map((v) => v.productId)
.filter((v, i, a) => a.findIndex((c) => c === v) === i),
work: body.productServiceList?.map((v) => v.workId || []).flat(),
service: body.productServiceList?.map((v) => v.serviceId || []).flat(),
};
const [customerBranch, employee, product, work, service] = await prisma.$transaction(
async (tx) =>
await Promise.all([
tx.customerBranch.findUnique({
include: {
customer: {
include: {
registeredBranch: {
include: branchRelationPermInclude(req.user),
},
},
},
},
where: { id: body.customerBranchId },
}),
tx.employee.findMany({ where: { id: { in: ids.employee } } }),
tx.product.findMany({ where: { id: { in: ids.product } } }),
ids.work?.length ? tx.work.findMany({ where: { id: { in: ids.work } } }) : null,
ids.service?.length ? tx.service.findMany({ where: { id: { in: ids.service } } }) : null,
]),
);
if (body.customerBranchId && !customerBranch) throw relationError("Customer Branch");
if (ids.employee && ids.employee.length !== employee.length) throw relationError("Worker");
if (ids.product && ids.product.length !== product.length) throw relationError("Product");
if (ids.work && ids.work.length && ids.work.length !== work?.length) {
throw relationError("Work");
}
if (ids.service && ids.service.length && ids.service.length !== service?.length) {
throw relationError("Service");
}
await permissionCheck(req.user, record.customerBranch.customer.registeredBranch);
if (customerBranch && record.customerBranchId !== body.customerBranchId) {
await permissionCheck(req.user, customerBranch.customer.registeredBranch);
}
const { productServiceList: _productServiceList, worker: _worker, ...rest } = body;
return await prisma.$transaction(async (tx) => {
const sortedEmployeeId: string[] = [];
const branchId = (customerBranch || record.customerBranch).id;
const branchCode = (customerBranch || record.customerBranch).code;
if (body.worker) {
const nonExistEmployee = body.worker.filter((v) => typeof v !== "string");
const lastEmployee = await tx.runningNo.upsert({
where: {
key: `EMPLOYEE_${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
},
create: {
key: `EMPLOYEE_${branchCode}-${`${new Date().getFullYear()}`.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: `${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`,
customerBranchId: branchId,
},
}),
),
);
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 list = body.productServiceList?.map((v, i) => ({
order: i + 1,
productId: v.productId,
workId: v.workId,
serviceId: v.serviceId,
pricePerUnit: v.pricePerUnit || product.find((p) => p.id === v.productId)?.price || 0,
amount: v.amount,
discount: v.discount || 0,
vat: v.vat || VAT_DEFAULT,
}));
const price = list?.reduce(
(a, c) => {
const price = c.pricePerUnit * c.amount;
const discount = price * c.discount;
const vat = price - discount * c.vat;
a.totalPrice += price;
a.totalDiscount += discount;
a.vat += vat;
a.finalPrice += price - discount + vat;
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
finalPrice: 0,
},
);
return await tx.quotation.update({
include: {
productServiceList: {
include: {
product: true,
work: true,
service: true,
},
},
paySplit: true,
worker: true,
customerBranch: {
include: { customer: true },
},
_count: {
select: { productServiceList: true },
},
},
where: { id: quotationId },
data: {
...rest,
...price,
statusOrder: +(rest.status === "INACTIVE"),
worker:
sortedEmployeeId.length > 0
? {
deleteMany: { id: { notIn: sortedEmployeeId } },
createMany: {
skipDuplicates: true,
data: sortedEmployeeId.map((v, i) => ({
no: i,
code: "",
employeeId: v,
})),
},
}
: undefined,
paySplit: rest.paySplit
? {
deleteMany: {},
createMany: {
data: (rest.paySplit || []).map((v, i) => ({
no: i + 1,
...v,
})),
},
}
: undefined,
productServiceList: {
deleteMany: {},
create: list,
},
updatedByUserId: req.user.sub,
},
});
});
}
@Delete("{quotationId}")
@Security("keycloak", MANAGE_ROLES)
async deleteQuotationById(@Request() req: RequestWithUser, @Path() quotationId: string) {
const record = await prisma.quotation.findUnique({
include: {
customerBranch: {
include: {
customer: {
include: {
registeredBranch: { include: branchRelationPermInclude(req.user) },
},
},
},
},
},
where: { id: quotationId },
});
if (!record) throw notFoundError("Quotation");
await permissionCheck(req.user, record.customerBranch.customer.registeredBranch);
if (record.status !== Status.CREATED) throw isUsedError("Quotation");
return await prisma.quotation.delete({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: quotationId },
});
}
}