1110 lines
33 KiB
TypeScript
1110 lines
33 KiB
TypeScript
import { PayCondition, Prisma, QuotationStatus, Status } from "@prisma/client";
|
|
import config from "../config.json";
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Head,
|
|
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";
|
|
import { precisionRound } from "../utils/arithmetic";
|
|
import { queryOrNot } from "../utils/relation";
|
|
import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio";
|
|
import HttpError from "../interfaces/http-error";
|
|
import HttpStatus from "../interfaces/http-status";
|
|
import { RequestWorkStatus } from "../generated/kysely/types";
|
|
|
|
type QuotationCreate = {
|
|
registeredBranchId: string;
|
|
status?: Status;
|
|
remark?: string | null;
|
|
|
|
workName: string;
|
|
contactName: string;
|
|
contactTel: string;
|
|
dueDate: Date;
|
|
|
|
payCondition: PayCondition;
|
|
|
|
paySplitCount?: number;
|
|
paySplit?: { name?: string | null; amount: number }[];
|
|
|
|
payBillDate?: Date;
|
|
|
|
// 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;
|
|
}
|
|
)[];
|
|
|
|
workerMax?: number | null;
|
|
|
|
customerBranchId: string;
|
|
|
|
urgent?: boolean;
|
|
|
|
agentPrice?: boolean;
|
|
discount?: number;
|
|
|
|
productServiceList: {
|
|
serviceId?: string;
|
|
workId?: string;
|
|
productId: string;
|
|
amount: number;
|
|
discount?: number;
|
|
installmentNo?: number;
|
|
workerIndex?: number[];
|
|
}[];
|
|
};
|
|
|
|
type QuotationUpdate = {
|
|
registeredBranchId?: string;
|
|
status?: "ACTIVE" | "INACTIVE";
|
|
|
|
quotationStatus?: "Accepted";
|
|
|
|
remark?: string | null;
|
|
|
|
workName?: string;
|
|
contactName?: string;
|
|
contactTel?: string;
|
|
dueDate?: Date;
|
|
|
|
payCondition?: PayCondition;
|
|
|
|
paySplitCount?: number;
|
|
paySplit?: { name?: string | null; amount: number }[];
|
|
|
|
payBillDate?: Date;
|
|
|
|
// 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;
|
|
}
|
|
)[];
|
|
|
|
workerMax?: number | null;
|
|
|
|
customerBranchId?: string;
|
|
|
|
urgent?: boolean;
|
|
|
|
discount?: number;
|
|
|
|
productServiceList?: {
|
|
serviceId?: string;
|
|
workId?: string;
|
|
productId: string;
|
|
amount: number;
|
|
discount?: number;
|
|
installmentNo?: number;
|
|
workerIndex?: number[];
|
|
}[];
|
|
};
|
|
|
|
const VAT_DEFAULT = config.vat;
|
|
|
|
const MANAGE_ROLES = [
|
|
"system",
|
|
"head_of_admin",
|
|
"admin",
|
|
"head_of_accountant",
|
|
"accountant",
|
|
"head_of_sale",
|
|
"sale",
|
|
];
|
|
|
|
function globalAllow(user: RequestWithUser["user"]) {
|
|
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"];
|
|
return allowList.some((v) => user.roles?.includes(v));
|
|
}
|
|
|
|
const permissionCheckCompany = createPermCheck((_) => true);
|
|
const permissionCheck = createPermCheck(globalAllow);
|
|
const permissionCond = createPermCondition(globalAllow);
|
|
|
|
@Route("/api/v1/quotation")
|
|
@Tags("Quotation")
|
|
export class QuotationController extends Controller {
|
|
@Get("stats")
|
|
@Security("keycloak")
|
|
async getProductStats(@Request() req: RequestWithUser) {
|
|
const result = await prisma.quotation.groupBy({
|
|
_count: true,
|
|
by: "quotationStatus",
|
|
where: {
|
|
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
|
|
isDebitNote: false,
|
|
},
|
|
});
|
|
|
|
return result.reduce<Record<string, number>>((a, c) => {
|
|
a[c.quotationStatus.charAt(0).toLowerCase() + c.quotationStatus.slice(1)] = c._count;
|
|
return a;
|
|
}, {});
|
|
}
|
|
@Get()
|
|
@Security("keycloak")
|
|
async getQuotationList(
|
|
@Request() req: RequestWithUser,
|
|
@Query() page: number = 1,
|
|
@Query() pageSize: number = 30,
|
|
@Query() payCondition?: PayCondition,
|
|
@Query() status?: QuotationStatus,
|
|
@Query() urgentFirst?: boolean,
|
|
@Query() includeRegisteredBranch?: boolean,
|
|
@Query() hasCancel?: boolean,
|
|
@Query() code?: string,
|
|
@Query() query = "",
|
|
) {
|
|
const where = {
|
|
OR: queryOrNot<Prisma.QuotationWhereInput[]>(query, [
|
|
{ code: { contains: query, mode: "insensitive" } },
|
|
{ workName: { contains: query } },
|
|
{
|
|
customerBranch: {
|
|
OR: [
|
|
{ code: { contains: query, mode: "insensitive" } },
|
|
{ customerName: { contains: query } },
|
|
{ firstName: { contains: query } },
|
|
{ firstNameEN: { contains: query } },
|
|
{ lastName: { contains: query } },
|
|
{ lastNameEN: { contains: query } },
|
|
],
|
|
},
|
|
},
|
|
]),
|
|
isDebitNote: false,
|
|
code,
|
|
payCondition,
|
|
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
|
|
quotationStatus: status,
|
|
requestData: hasCancel
|
|
? {
|
|
some: {
|
|
requestWork: {
|
|
some: {
|
|
creditNoteId: null,
|
|
stepStatus: { some: { workStatus: RequestWorkStatus.Canceled } },
|
|
},
|
|
},
|
|
},
|
|
}
|
|
: undefined,
|
|
} satisfies Prisma.QuotationWhereInput;
|
|
|
|
const [result, total] = await prisma.$transaction([
|
|
prisma.quotation.findMany({
|
|
where,
|
|
include: {
|
|
_count: {
|
|
select: { worker: true },
|
|
},
|
|
registeredBranch: includeRegisteredBranch,
|
|
customerBranch: {
|
|
include: {
|
|
customer: {
|
|
include: { registeredBranch: true },
|
|
},
|
|
},
|
|
},
|
|
invoice: {
|
|
include: { payment: true },
|
|
},
|
|
paySplit: {
|
|
orderBy: { no: "asc" },
|
|
},
|
|
createdBy: true,
|
|
updatedBy: true,
|
|
},
|
|
orderBy: urgentFirst ? [{ urgent: "desc" }, { createdAt: "desc" }] : { createdAt: "desc" },
|
|
take: pageSize,
|
|
skip: (page - 1) * pageSize,
|
|
}),
|
|
prisma.quotation.count({ where }),
|
|
]);
|
|
|
|
if (hasCancel) {
|
|
const canceled = await prisma.requestData.findMany({
|
|
include: {
|
|
_count: {
|
|
select: {
|
|
requestWork: {
|
|
where: {
|
|
creditNoteId: null,
|
|
stepStatus: { some: { workStatus: RequestWorkStatus.Canceled } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
where: {
|
|
requestWork: {
|
|
some: {
|
|
creditNoteId: null,
|
|
stepStatus: { some: { workStatus: RequestWorkStatus.Canceled } },
|
|
},
|
|
},
|
|
quotationId: { in: result.map((v) => v.id) },
|
|
},
|
|
});
|
|
|
|
return {
|
|
result: result.map((v) => {
|
|
const canceledCount = canceled
|
|
.filter((item) => item.quotationId === v.id)
|
|
.reduce((a, c) => a + c._count.requestWork, 0);
|
|
return Object.assign(v, {
|
|
_count: { ...v._count, canceledWork: canceledCount },
|
|
});
|
|
}),
|
|
page,
|
|
pageSize,
|
|
total,
|
|
};
|
|
}
|
|
|
|
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 },
|
|
},
|
|
registeredBranch: true,
|
|
customerBranch: {
|
|
include: {
|
|
province: true,
|
|
district: true,
|
|
subDistrict: true,
|
|
},
|
|
},
|
|
worker: {
|
|
include: {
|
|
employee: {
|
|
include: {
|
|
employeePassport: {
|
|
orderBy: { expireDate: "desc" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
debitNote: true,
|
|
productServiceList: {
|
|
include: {
|
|
service: {
|
|
include: {
|
|
productGroup: true,
|
|
work: {
|
|
include: {
|
|
productOnWork: {
|
|
include: {
|
|
product: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
work: true,
|
|
product: {
|
|
include: { productGroup: true },
|
|
},
|
|
|
|
worker: true,
|
|
},
|
|
},
|
|
paySplit: {
|
|
include: { invoice: true },
|
|
orderBy: { no: "asc" },
|
|
},
|
|
createdBy: true,
|
|
updatedBy: true,
|
|
},
|
|
where: { id: quotationId, isDebitNote: false },
|
|
});
|
|
|
|
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 || [])
|
|
.filter((v, i, a) => a.findIndex((c) => c === v) === i)
|
|
.flat(),
|
|
service: body.productServiceList
|
|
.map((v) => v.serviceId || [])
|
|
.filter((v, i, a) => a.findIndex((c) => c === v) === i)
|
|
.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 permissionCheckCompany(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.id}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
|
|
},
|
|
create: {
|
|
key: `EMPLOYEE_${customerBranch.id}-${`${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 lastQuotation = await tx.runningNo.upsert({
|
|
where: {
|
|
key: `QUOTATION_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}`,
|
|
},
|
|
create: {
|
|
key: `QUOTATION_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}`,
|
|
value: 1,
|
|
},
|
|
update: { value: { increment: 1 } },
|
|
});
|
|
|
|
const list = body.productServiceList.map((v, i) => {
|
|
const p = product.find((p) => p.id === v.productId)!;
|
|
|
|
const originalPrice = body.agentPrice ? p.agentPrice : p.price;
|
|
const finalPriceWithVat = precisionRound(
|
|
originalPrice + (p.vatIncluded ? 0 : originalPrice * VAT_DEFAULT),
|
|
);
|
|
|
|
const price = finalPriceWithVat;
|
|
const pricePerUnit = price / (1 + VAT_DEFAULT);
|
|
const vat = p.calcVat ? (pricePerUnit * v.amount - (v.discount || 0)) * VAT_DEFAULT : 0;
|
|
|
|
return {
|
|
order: i + 1,
|
|
productId: v.productId,
|
|
workId: v.workId,
|
|
serviceId: v.serviceId,
|
|
pricePerUnit,
|
|
amount: v.amount,
|
|
discount: v.discount || 0,
|
|
installmentNo: v.installmentNo,
|
|
vat,
|
|
worker: {
|
|
create: sortedEmployeeId
|
|
.filter((_, i) => !v.workerIndex || i in v.workerIndex)
|
|
.map((employeeId) => ({ employeeId })),
|
|
},
|
|
};
|
|
});
|
|
|
|
const price = list.reduce(
|
|
(a, c) => {
|
|
a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount);
|
|
a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
|
|
a.vat = precisionRound(a.vat + c.vat);
|
|
a.vatExcluded =
|
|
c.vat === 0
|
|
? precisionRound(
|
|
a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT,
|
|
)
|
|
: a.vatExcluded;
|
|
a.finalPrice = precisionRound(
|
|
Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0),
|
|
);
|
|
|
|
return a;
|
|
},
|
|
{
|
|
totalPrice: 0,
|
|
totalDiscount: 0,
|
|
vat: 0,
|
|
vatExcluded: 0,
|
|
discount: body.discount,
|
|
finalPrice: 0,
|
|
},
|
|
);
|
|
|
|
await Promise.all([
|
|
tx.service.updateMany({
|
|
where: { id: { in: ids.service }, status: Status.CREATED },
|
|
data: { status: Status.ACTIVE },
|
|
}),
|
|
tx.product.updateMany({
|
|
where: { id: { in: ids.product }, status: Status.CREATED },
|
|
data: { status: Status.ACTIVE },
|
|
}),
|
|
]);
|
|
|
|
return await tx.quotation.create({
|
|
include: {
|
|
productServiceList: {
|
|
include: {
|
|
service: {
|
|
include: {
|
|
productGroup: true,
|
|
work: {
|
|
include: {
|
|
productOnWork: {
|
|
include: {
|
|
product: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
work: true,
|
|
product: {
|
|
include: { productGroup: true },
|
|
},
|
|
|
|
worker: true,
|
|
},
|
|
},
|
|
paySplit: {
|
|
include: { invoice: true },
|
|
},
|
|
worker: true,
|
|
customerBranch: {
|
|
include: { customer: true },
|
|
},
|
|
_count: {
|
|
select: { productServiceList: true },
|
|
},
|
|
},
|
|
|
|
data: {
|
|
...rest,
|
|
...price,
|
|
statusOrder: +(rest.status === "INACTIVE"),
|
|
code: `QT${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${lastQuotation.value.toString().padStart(6, "0")}`,
|
|
worker: {
|
|
createMany: {
|
|
data: sortedEmployeeId.map((v, i) => ({
|
|
no: i,
|
|
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: {
|
|
registeredBranch: { include: branchRelationPermInclude(req.user) },
|
|
customerBranch: {
|
|
include: {
|
|
customer: {
|
|
include: {
|
|
registeredBranch: { include: branchRelationPermInclude(req.user) },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
where: { id: quotationId, isDebitNote: false },
|
|
});
|
|
|
|
if (!record) throw notFoundError("Quotation");
|
|
|
|
await permissionCheckCompany(req.user, record.registeredBranch);
|
|
|
|
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 || [])
|
|
.filter((v, i, a) => a.findIndex((c) => c === v) === i)
|
|
.flat(),
|
|
service: body.productServiceList
|
|
?.map((v) => v.serviceId || [])
|
|
.filter((v, i, a) => a.findIndex((c) => c === v) === i)
|
|
.flat(),
|
|
};
|
|
|
|
const [customerBranch, employee, product, work, service] = await prisma.$transaction(
|
|
async (tx) =>
|
|
await Promise.all([
|
|
tx.customerBranch.findFirst({
|
|
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({
|
|
include: { work: { include: { productOnWork: true } } },
|
|
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 permissionCheckCompany(req.user, record.customerBranch.customer.registeredBranch);
|
|
if (
|
|
customerBranch &&
|
|
body.customerBranchId &&
|
|
record.customerBranchId !== body.customerBranchId
|
|
) {
|
|
await permissionCheckCompany(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) => {
|
|
const p = product.find((p) => p.id === v.productId)!;
|
|
|
|
const originalPrice = record.agentPrice ? p.agentPrice : p.price;
|
|
const finalPriceWithVat = precisionRound(
|
|
originalPrice + (p.vatIncluded ? 0 : originalPrice * VAT_DEFAULT),
|
|
);
|
|
|
|
const price = finalPriceWithVat;
|
|
const pricePerUnit = price / (1 + VAT_DEFAULT);
|
|
const vat = p.calcVat ? (pricePerUnit * v.amount - (v.discount || 0)) * VAT_DEFAULT : 0;
|
|
|
|
return {
|
|
order: i + 1,
|
|
productId: v.productId,
|
|
workId: v.workId,
|
|
serviceId: v.serviceId,
|
|
pricePerUnit,
|
|
amount: v.amount,
|
|
discount: v.discount || 0,
|
|
installmentNo: v.installmentNo,
|
|
vat,
|
|
worker: {
|
|
create: sortedEmployeeId
|
|
.filter((_, i) => !v.workerIndex || i in v.workerIndex)
|
|
.map((employeeId) => ({ employeeId })),
|
|
},
|
|
};
|
|
});
|
|
|
|
const price = list?.reduce(
|
|
(a, c) => {
|
|
a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount);
|
|
a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
|
|
a.vat = precisionRound(a.vat + c.vat);
|
|
a.vatExcluded =
|
|
c.vat === 0
|
|
? precisionRound(
|
|
a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT,
|
|
)
|
|
: a.vatExcluded;
|
|
a.finalPrice = precisionRound(
|
|
Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0),
|
|
);
|
|
|
|
return a;
|
|
},
|
|
{
|
|
totalPrice: 0,
|
|
totalDiscount: 0,
|
|
vat: 0,
|
|
vatExcluded: 0,
|
|
discount: body.discount,
|
|
finalPrice: 0,
|
|
},
|
|
);
|
|
|
|
await Promise.all([
|
|
tx.service.updateMany({
|
|
where: { id: { in: ids.service }, status: Status.CREATED },
|
|
data: { status: Status.ACTIVE },
|
|
}),
|
|
tx.product.updateMany({
|
|
where: { id: { in: ids.product }, status: Status.CREATED },
|
|
data: { status: Status.ACTIVE },
|
|
}),
|
|
]);
|
|
|
|
return await tx.quotation.update({
|
|
include: {
|
|
productServiceList: {
|
|
include: {
|
|
service: {
|
|
include: {
|
|
productGroup: true,
|
|
work: {
|
|
include: {
|
|
productOnWork: {
|
|
include: {
|
|
product: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
work: true,
|
|
product: {
|
|
include: { productGroup: true },
|
|
},
|
|
|
|
worker: true,
|
|
},
|
|
},
|
|
paySplit: true,
|
|
worker: true,
|
|
customerBranch: {
|
|
include: { customer: true },
|
|
},
|
|
_count: {
|
|
select: { productServiceList: true },
|
|
},
|
|
},
|
|
where: { id: quotationId, isDebitNote: false },
|
|
data: {
|
|
...rest,
|
|
...price,
|
|
quotationStatus: record.quotationStatus === "Expired" ? "Issued" : rest.quotationStatus,
|
|
statusOrder: +(rest.status === "INACTIVE"),
|
|
worker:
|
|
sortedEmployeeId.length > 0
|
|
? {
|
|
deleteMany: { id: { notIn: sortedEmployeeId } },
|
|
createMany: {
|
|
skipDuplicates: true,
|
|
data: sortedEmployeeId.map((v, i) => ({
|
|
no: i,
|
|
employeeId: v,
|
|
})),
|
|
},
|
|
}
|
|
: undefined,
|
|
paySplit: rest.paySplit
|
|
? {
|
|
deleteMany: {},
|
|
createMany: {
|
|
data: (rest.paySplit || []).map((v, i) => ({
|
|
no: i + 1,
|
|
...v,
|
|
})),
|
|
},
|
|
}
|
|
: undefined,
|
|
productServiceList: list
|
|
? {
|
|
deleteMany: {},
|
|
create: list,
|
|
}
|
|
: undefined,
|
|
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: {
|
|
registeredBranch: { include: branchRelationPermInclude(req.user) },
|
|
},
|
|
where: { id: quotationId, isDebitNote: false },
|
|
});
|
|
|
|
if (!record) throw notFoundError("Quotation");
|
|
|
|
await permissionCheck(req.user, record.registeredBranch);
|
|
|
|
if (record.status !== Status.CREATED) throw isUsedError("Quotation");
|
|
|
|
return await prisma.quotation.delete({
|
|
include: {
|
|
createdBy: true,
|
|
updatedBy: true,
|
|
},
|
|
where: { id: quotationId },
|
|
});
|
|
}
|
|
}
|
|
|
|
@Route("api/v1/quotation/{quotationId}")
|
|
@Tags("Quotation")
|
|
export class QuotationActionController extends Controller {
|
|
@Post("add-worker")
|
|
async addWorker(
|
|
@Request() req: RequestWithUser,
|
|
@Path() quotationId: string,
|
|
@Body()
|
|
body: {
|
|
workerId: string;
|
|
productServiceId: string[];
|
|
}[],
|
|
) {
|
|
const ids = {
|
|
employee: body.map((v) => v.workerId),
|
|
productService: body
|
|
.flatMap((v) => v.productServiceId)
|
|
.filter((lhs, i, a) => a.findIndex((rhs) => lhs === rhs) === i),
|
|
};
|
|
|
|
const [quotation, employee, productService] = await prisma.$transaction(
|
|
async (tx) =>
|
|
await Promise.all([
|
|
tx.quotation.findFirst({
|
|
include: {
|
|
worker: true,
|
|
_count: {
|
|
select: {
|
|
worker: true,
|
|
},
|
|
},
|
|
},
|
|
where: { id: quotationId, isDebitNote: false },
|
|
}),
|
|
tx.employee.findMany({
|
|
where: { id: { in: ids.employee } },
|
|
take: ids.employee.length + 1, // NOTE: Do not find further as it should be equal to input
|
|
}),
|
|
tx.quotationProductServiceList.findMany({
|
|
where: { id: { in: ids.productService }, quotationId },
|
|
take: ids.productService.length + 1, // NOTE: Do not find further as it should be equal to input
|
|
}),
|
|
]),
|
|
);
|
|
|
|
if (!quotation) throw relationError("Quotation");
|
|
if (ids.employee.length !== employee.length) throw relationError("Worker");
|
|
if (ids.productService.length !== productService.length) throw relationError("Product");
|
|
if (
|
|
quotation._count.worker +
|
|
body.filter((lhs) => !quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId))
|
|
.length >
|
|
(quotation.workerMax || 0)
|
|
) {
|
|
if (body.length === 0) return;
|
|
throw new HttpError(
|
|
HttpStatus.PRECONDITION_FAILED,
|
|
"Worker exceed current quotation max worker.",
|
|
"QuotationWorkerExceed",
|
|
);
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.quotationProductServiceWorker.createMany({
|
|
data: body
|
|
.filter((lhs) => !quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId))
|
|
.flatMap((lhs) =>
|
|
lhs.productServiceId.map((rhs) => ({
|
|
employeeId: lhs.workerId,
|
|
productServiceId: rhs,
|
|
})),
|
|
),
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
const current = new Date();
|
|
const year = `${current.getFullYear()}`.slice(-2).padStart(2, "0");
|
|
const month = `${current.getMonth() + 1}`.padStart(2, "0");
|
|
|
|
const lastRequest = await tx.runningNo.upsert({
|
|
where: {
|
|
key: `REQUEST_${year}${month}`,
|
|
},
|
|
create: {
|
|
key: `REQUEST_${year}${month}`,
|
|
value: quotation.worker.length,
|
|
},
|
|
update: { value: { increment: quotation.worker.length } },
|
|
});
|
|
await tx.quotation.update({
|
|
where: { id: quotationId, isDebitNote: false },
|
|
data: {
|
|
quotationStatus: QuotationStatus.PaymentSuccess, // NOTE: change back if already complete or canceled
|
|
worker: {
|
|
createMany: {
|
|
data: body
|
|
.filter((lhs) => !quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId))
|
|
.map((v, i) => ({
|
|
no: quotation._count.worker + i + 1,
|
|
employeeId: v.workerId,
|
|
})),
|
|
},
|
|
},
|
|
requestData:
|
|
quotation.quotationStatus === "PaymentInProcess" ||
|
|
quotation.quotationStatus === "PaymentSuccess"
|
|
? {
|
|
create: body
|
|
.filter(
|
|
(lhs) =>
|
|
!quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId) &&
|
|
lhs.productServiceId.length > 0,
|
|
)
|
|
.map((v, i) => ({
|
|
code: `TR${year}${month}${(lastRequest.value - quotation._count.worker + i + 1).toString().padStart(6, "0")}`,
|
|
employeeId: v.workerId,
|
|
requestWork: {
|
|
create: v.productServiceId.map((v) => ({ productServiceId: v })),
|
|
},
|
|
})),
|
|
}
|
|
: undefined,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
@Route("api/v1/quotation/{quotationId}/attachment")
|
|
@Tags("Quotation")
|
|
export class QuotationFileController extends Controller {
|
|
async #checkPermission(user: RequestWithUser["user"], id: string) {
|
|
const data = await prisma.quotation.findUnique({
|
|
include: {
|
|
registeredBranch: {
|
|
include: branchRelationPermInclude(user),
|
|
},
|
|
},
|
|
where: { id, isDebitNote: false },
|
|
});
|
|
if (!data) throw notFoundError("Quotation");
|
|
await permissionCheck(user, data.registeredBranch);
|
|
}
|
|
|
|
@Get()
|
|
@Security("keycloak")
|
|
async listAttachment(@Request() req: RequestWithUser, @Path() quotationId: string) {
|
|
await this.#checkPermission(req.user, quotationId);
|
|
return await listFile(fileLocation.quotation.attachment(quotationId));
|
|
}
|
|
|
|
@Head("{name}")
|
|
async headAttachment(
|
|
@Request() req: RequestWithUser,
|
|
@Path() quotationId: string,
|
|
@Path() name: string,
|
|
) {
|
|
return req.res?.redirect(
|
|
await getPresigned("head", fileLocation.quotation.attachment(quotationId, name)),
|
|
);
|
|
}
|
|
|
|
@Get("{name}")
|
|
@Security("keycloak")
|
|
async getAttachment(
|
|
@Request() req: RequestWithUser,
|
|
@Path() quotationId: string,
|
|
@Path() name: string,
|
|
) {
|
|
await this.#checkPermission(req.user, quotationId);
|
|
return await getFile(fileLocation.quotation.attachment(quotationId, name));
|
|
}
|
|
|
|
@Put("{name}")
|
|
@Security("keycloak", MANAGE_ROLES)
|
|
async putAttachment(
|
|
@Request() req: RequestWithUser,
|
|
@Path() quotationId: string,
|
|
@Path() name: string,
|
|
) {
|
|
await this.#checkPermission(req.user, quotationId);
|
|
return await setFile(fileLocation.quotation.attachment(quotationId, name));
|
|
}
|
|
|
|
@Delete("{name}")
|
|
@Security("keycloak", MANAGE_ROLES)
|
|
async deleteAttachment(
|
|
@Request() req: RequestWithUser,
|
|
@Path() quotationId: string,
|
|
@Path() name: string,
|
|
) {
|
|
await this.#checkPermission(req.user, quotationId);
|
|
return await deleteFile(fileLocation.quotation.attachment(quotationId, name));
|
|
}
|
|
}
|