jws-backend/src/controllers/05-quotation-controller.ts

1111 lines
33 KiB
TypeScript
Raw Normal View History

import { PayCondition, Prisma, QuotationStatus, Status } from "@prisma/client";
2024-10-04 16:48:23 +07:00
import config from "../config.json";
2024-07-18 17:23:41 +07:00
import {
Body,
Controller,
Delete,
Get,
2024-11-01 13:43:57 +07:00
Head,
2024-07-18 17:23:41 +07:00
Path,
Post,
Put,
Query,
Request,
Route,
Security,
Tags,
} from "tsoa";
import { RequestWithUser } from "../interfaces/user";
2024-07-19 10:41:37 +07:00
import prisma from "../db";
import {
branchRelationPermInclude,
createPermCheck,
createPermCondition,
} from "../services/permission";
import { isSystem } from "../utils/keycloak";
import { isUsedError, notFoundError, relationError } from "../utils/error";
2024-10-04 13:01:30 +07:00
import { precisionRound } from "../utils/arithmetic";
import { queryOrNot } from "../utils/relation";
2024-11-01 13:43:57 +07:00
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";
2024-07-18 17:23:41 +07:00
type QuotationCreate = {
registeredBranchId: string;
2024-07-18 17:23:41 +07:00
status?: Status;
2024-10-18 14:04:55 +07:00
remark?: string | null;
2024-07-18 17:23:41 +07:00
workName: string;
contactName: string;
contactTel: string;
dueDate: Date;
2024-07-18 17:23:41 +07:00
payCondition: PayCondition;
paySplitCount?: number;
2024-11-01 11:14:12 +07:00
paySplit?: { name?: string | null; amount: number }[];
2024-07-18 17:23:41 +07:00
payBillDate?: Date;
2024-07-18 17:23:41 +07:00
2024-07-19 10:41:37 +07:00
// EmployeeId or Create new employee
worker: (
| string
| {
dateOfBirth: Date;
gender: string;
nationality: string;
2024-09-19 14:31:24 +07:00
namePrefix?: string;
2024-07-19 10:41:37 +07:00
firstName: string;
firstNameEN: string;
2024-09-19 14:31:24 +07:00
middleName?: string;
middleNameEN?: string;
2024-07-19 10:41:37 +07:00
lastName: string;
lastNameEN: string;
}
)[];
2024-10-18 14:48:50 +07:00
workerMax?: number | null;
2024-07-19 10:41:37 +07:00
customerBranchId: string;
2024-07-18 17:23:41 +07:00
urgent?: boolean;
2024-10-04 13:31:29 +07:00
agentPrice?: boolean;
2024-10-04 16:48:23 +07:00
discount?: number;
2024-10-04 13:31:29 +07:00
productServiceList: {
serviceId?: string;
workId?: string;
productId: string;
amount: number;
2024-10-03 13:59:46 +07:00
discount?: number;
installmentNo?: number;
2024-10-04 16:48:23 +07:00
workerIndex?: number[];
2024-07-19 10:41:37 +07:00
}[];
2024-07-18 17:23:41 +07:00
};
type QuotationUpdate = {
registeredBranchId?: string;
2024-07-18 17:23:41 +07:00
status?: "ACTIVE" | "INACTIVE";
quotationStatus?: "Accepted";
2024-10-18 14:04:55 +07:00
remark?: string | null;
2024-07-18 17:23:41 +07:00
workName?: string;
contactName?: string;
contactTel?: string;
dueDate?: Date;
2024-07-24 13:33:46 +07:00
payCondition?: PayCondition;
2024-07-18 17:23:41 +07:00
paySplitCount?: number;
2024-11-01 11:14:12 +07:00
paySplit?: { name?: string | null; amount: number }[];
2024-07-18 17:23:41 +07:00
payBillDate?: Date;
2024-07-18 17:23:41 +07:00
2024-07-19 10:41:37 +07:00
// EmployeeId or Create new employee
worker?: (
| string
| {
dateOfBirth: Date;
gender: string;
nationality: string;
2024-09-19 14:31:24 +07:00
namePrefix?: string;
2024-07-19 10:41:37 +07:00
firstName: string;
firstNameEN: string;
2024-09-19 14:31:24 +07:00
middleName?: string;
middleNameEN?: string;
2024-07-19 10:41:37 +07:00
lastName: string;
lastNameEN: string;
}
)[];
2024-10-18 14:48:50 +07:00
workerMax?: number | null;
2024-07-24 13:33:46 +07:00
customerBranchId?: string;
2024-07-18 17:23:41 +07:00
urgent?: boolean;
2024-10-04 16:48:23 +07:00
discount?: number;
productServiceList?: {
serviceId?: string;
workId?: string;
productId: string;
amount: number;
2024-10-07 15:29:45 +07:00
discount?: number;
installmentNo?: number;
2024-10-04 16:48:23 +07:00
workerIndex?: number[];
2024-07-19 10:41:37 +07:00
}[];
2024-07-18 17:23:41 +07:00
};
2024-10-04 16:48:23 +07:00
const VAT_DEFAULT = config.vat;
2024-07-24 14:26:23 +07:00
const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"head_of_accountant",
"accountant",
"head_of_sale",
"sale",
2024-07-24 14:26:23 +07:00
];
2024-09-04 15:19:15 +07:00
function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"];
2024-09-04 15:19:15 +07:00
return allowList.some((v) => user.roles?.includes(v));
2024-07-24 14:26:23 +07:00
}
const permissionCheckCompany = createPermCheck((_) => true);
const permissionCheck = createPermCheck(globalAllow);
const permissionCond = createPermCondition(globalAllow);
2024-07-18 17:23:41 +07:00
@Route("/api/v1/quotation")
@Tags("Quotation")
export class QuotationController extends Controller {
2024-10-07 10:19:32 +07:00
@Get("stats")
@Security("keycloak")
async getProductStats(@Request() req: RequestWithUser) {
const result = await prisma.quotation.groupBy({
_count: true,
2024-11-07 12:59:37 +07:00
by: "quotationStatus",
2024-10-07 10:19:32 +07:00
where: {
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
isDebitNote: false,
2024-10-07 10:19:32 +07:00
},
});
return result.reduce<Record<string, number>>((a, c) => {
2024-11-07 12:59:37 +07:00
a[c.quotationStatus.charAt(0).toLowerCase() + c.quotationStatus.slice(1)] = c._count;
2024-10-07 10:19:32 +07:00
return a;
}, {});
}
@Get()
@Security("keycloak")
async getQuotationList(
@Request() req: RequestWithUser,
@Query() page: number = 1,
@Query() pageSize: number = 30,
2024-09-30 14:31:27 +07:00
@Query() payCondition?: PayCondition,
@Query() status?: QuotationStatus,
@Query() urgentFirst?: boolean,
2025-01-07 16:46:06 +07:00
@Query() includeRegisteredBranch?: boolean,
@Query() hasCancel?: boolean,
2024-12-25 10:41:48 +07:00
@Query() code?: string,
2024-10-18 08:46:17 +07:00
@Query() query = "",
) {
2024-09-30 14:31:27 +07:00
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,
2024-12-25 10:41:48 +07:00
code,
2024-09-30 14:31:27 +07:00
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,
2024-09-30 14:31:27 +07:00
} satisfies Prisma.QuotationWhereInput;
const [result, total] = await prisma.$transaction([
prisma.quotation.findMany({
2024-09-30 14:31:27 +07:00
where,
include: {
2024-10-03 14:30:35 +07:00
_count: {
select: { worker: true },
},
2025-01-07 16:46:06 +07:00
registeredBranch: includeRegisteredBranch,
customerBranch: {
include: {
customer: {
include: { registeredBranch: true },
},
},
},
invoice: {
include: { payment: true },
},
2024-11-26 13:53:57 +07:00
paySplit: {
orderBy: { no: "asc" },
},
2024-10-15 09:43:34 +07:00
createdBy: true,
updatedBy: true,
},
orderBy: urgentFirst ? [{ urgent: "desc" }, { createdAt: "desc" }] : { createdAt: "desc" },
2024-10-07 10:39:32 +07:00
take: pageSize,
skip: (page - 1) * pageSize,
}),
2024-09-30 14:31:27 +07:00
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 };
}
2024-07-18 17:23:41 +07:00
@Get("{quotationId}")
@Security("keycloak")
2024-07-19 10:41:37 +07:00
async getQuotationById(@Path() quotationId: string) {
const record = await prisma.quotation.findUnique({
include: {
_count: {
select: { worker: true },
},
registeredBranch: true,
2024-10-18 14:58:22 +07:00
customerBranch: {
include: {
province: true,
district: true,
subDistrict: true,
},
},
worker: {
2024-10-17 16:53:28 +07:00
include: {
employee: {
include: {
2024-10-17 16:54:13 +07:00
employeePassport: {
orderBy: { expireDate: "desc" },
},
2024-10-17 16:53:28 +07:00
},
},
},
},
debitNote: true,
productServiceList: {
2024-07-19 10:41:37 +07:00
include: {
2024-10-08 10:48:57 +07:00
service: {
include: {
2024-10-08 14:10:13 +07:00
productGroup: true,
2024-10-08 10:48:57 +07:00
work: {
include: {
productOnWork: {
include: {
product: true,
},
},
},
},
},
},
work: true,
2024-10-08 14:10:13 +07:00
product: {
include: { productGroup: true },
},
2024-10-08 10:48:57 +07:00
2024-10-07 13:50:29 +07:00
worker: true,
2024-07-19 10:41:37 +07:00
},
},
paySplit: {
include: { invoice: true },
orderBy: { no: "asc" },
},
2024-10-15 09:43:34 +07:00
createdBy: true,
updatedBy: true,
2024-07-19 10:41:37 +07:00
},
where: { id: quotationId, isDebitNote: false },
2024-07-19 10:41:37 +07:00
});
if (!record) throw notFoundError("Quotation");
2024-07-19 10:41:37 +07:00
return record;
}
2024-07-18 17:23:41 +07:00
@Post()
2024-07-24 14:26:23 +07:00
@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),
2024-10-07 16:13:49 +07:00
work: body.productServiceList
.map((v) => v.workId || [])
.filter((v, i, a) => a.findIndex((c) => c === v) === i)
.flat(),
2024-10-07 16:17:31 +07:00
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,
2024-11-19 13:48:45 +07:00
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,
2024-10-29 09:14:40 +07:00
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);
}
}
2024-07-31 09:45:11 +07:00
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")}`,
2024-07-31 09:45:11 +07:00
},
create: {
key: `QUOTATION_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}`,
2024-07-31 09:45:11 +07:00
value: 1,
},
update: { value: { increment: 1 } },
});
2024-10-04 16:48:23 +07:00
const list = body.productServiceList.map((v, i) => {
const p = product.find((p) => p.id === v.productId)!;
2025-01-23 10:21:10 +07:00
const originalPrice = body.agentPrice ? p.agentPrice : p.price;
const finalPriceWithVat = precisionRound(
2025-01-23 11:31:36 +07:00
originalPrice + (p.vatIncluded ? 0 : originalPrice * VAT_DEFAULT),
2025-01-23 10:21:10 +07:00
);
const price = finalPriceWithVat;
2025-01-23 11:51:56 +07:00
const pricePerUnit = price / (1 + VAT_DEFAULT);
2025-01-23 10:21:10 +07:00
const vat = p.calcVat ? (pricePerUnit * v.amount - (v.discount || 0)) * VAT_DEFAULT : 0;
2024-10-04 16:48:23 +07:00
return {
order: i + 1,
productId: v.productId,
workId: v.workId,
serviceId: v.serviceId,
2024-10-08 10:02:49 +07:00
pricePerUnit,
2024-10-04 16:48:23 +07:00
amount: v.amount,
discount: v.discount || 0,
installmentNo: v.installmentNo,
2024-10-04 16:48:23 +07:00
vat,
worker: {
create: sortedEmployeeId
.filter((_, i) => !v.workerIndex || i in v.workerIndex)
.map((employeeId) => ({ employeeId })),
},
};
});
const price = list.reduce(
(a, c) => {
2024-10-07 14:51:16 +07:00
a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount);
2024-10-04 16:48:23 +07:00
a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
2024-10-07 15:29:45 +07:00
a.vat = precisionRound(a.vat + c.vat);
2024-10-17 15:52:29 +07:00
a.vatExcluded =
c.vat === 0
? precisionRound(
a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT,
)
2024-10-18 10:59:35 +07:00
: a.vatExcluded;
2024-10-09 09:18:37 +07:00
a.finalPrice = precisionRound(
Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0),
);
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
2024-10-17 15:52:29 +07:00
vatExcluded: 0,
2024-10-04 16:48:23 +07:00
discount: body.discount,
2024-10-09 09:18:37 +07:00
finalPrice: 0,
},
);
2024-12-20 14:33:07 +07:00
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: {
2024-10-08 14:10:13 +07:00
service: {
include: {
productGroup: true,
work: {
include: {
productOnWork: {
include: {
product: true,
},
},
},
},
},
},
work: true,
2024-10-08 14:10:13 +07:00
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"),
2024-11-07 09:34:00 +07:00
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,
2024-10-03 13:41:36 +07:00
...v,
})),
},
},
2024-10-04 16:48:23 +07:00
productServiceList: {
create: list,
},
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
});
}
2024-07-18 17:23:41 +07:00
@Put("{quotationId}")
2024-07-24 14:26:23 +07:00
@Security("keycloak", MANAGE_ROLES)
2024-07-18 17:23:41 +07:00
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");
2024-07-24 13:33:46 +07:00
2024-12-26 10:10:12 +07:00
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),
2024-10-09 17:59:07 +07:00
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(),
};
2024-07-24 13:33:46 +07:00
const [customerBranch, employee, product, work, service] = await prisma.$transaction(
2024-07-24 13:33:46 +07:00
async (tx) =>
await Promise.all([
2024-10-28 09:35:36 +07:00
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,
2024-11-18 09:18:00 +07:00
ids.service?.length
? tx.service.findMany({
include: { work: { include: { productOnWork: true } } },
where: { id: { in: ids.service } },
})
: null,
2024-07-24 13:33:46 +07:00
]),
);
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);
2024-07-24 13:33:46 +07:00
}
const { productServiceList: _productServiceList, worker: _worker, ...rest } = body;
2024-07-24 13:33:46 +07:00
return await prisma.$transaction(async (tx) => {
const sortedEmployeeId: string[] = [];
const branchId = (customerBranch || record.customerBranch).id;
const branchCode = (customerBranch || record.customerBranch).code;
2024-07-24 13:33:46 +07:00
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")}`,
2024-07-24 13:33:46 +07:00
},
create: {
key: `EMPLOYEE_${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
2024-07-24 13:33:46 +07:00
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,
2024-07-24 13:33:46 +07:00
},
}),
),
);
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);
}
}
}
2024-10-04 16:48:23 +07:00
const list = body.productServiceList?.map((v, i) => {
const p = product.find((p) => p.id === v.productId)!;
2025-01-23 10:21:10 +07:00
const originalPrice = record.agentPrice ? p.agentPrice : p.price;
const finalPriceWithVat = precisionRound(
2025-01-23 11:31:36 +07:00
originalPrice + (p.vatIncluded ? 0 : originalPrice * VAT_DEFAULT),
2025-01-23 10:21:10 +07:00
);
const price = finalPriceWithVat;
2025-01-23 11:51:56 +07:00
const pricePerUnit = price / (1 + VAT_DEFAULT);
2025-01-23 10:21:10 +07:00
const vat = p.calcVat ? (pricePerUnit * v.amount - (v.discount || 0)) * VAT_DEFAULT : 0;
2024-10-04 16:48:23 +07:00
return {
order: i + 1,
productId: v.productId,
workId: v.workId,
serviceId: v.serviceId,
2024-10-08 10:02:49 +07:00
pricePerUnit,
2024-10-04 16:48:23 +07:00
amount: v.amount,
discount: v.discount || 0,
installmentNo: v.installmentNo,
2024-10-04 16:48:23 +07:00
vat,
worker: {
create: sortedEmployeeId
.filter((_, i) => !v.workerIndex || i in v.workerIndex)
.map((employeeId) => ({ employeeId })),
},
};
});
const price = list?.reduce(
(a, c) => {
2024-10-07 14:51:16 +07:00
a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount);
2024-10-04 16:48:23 +07:00
a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
2024-10-07 15:29:45 +07:00
a.vat = precisionRound(a.vat + c.vat);
2024-10-17 15:52:29 +07:00
a.vatExcluded =
c.vat === 0
? precisionRound(
a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT,
)
2024-10-18 10:59:35 +07:00
: a.vatExcluded;
2024-10-09 09:18:37 +07:00
a.finalPrice = precisionRound(
Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0),
);
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
2024-10-17 15:52:29 +07:00
vatExcluded: 0,
2024-10-04 16:48:23 +07:00
discount: body.discount,
2024-10-09 09:18:37 +07:00
finalPrice: 0,
},
);
2024-07-24 13:33:46 +07:00
2024-12-20 14:33:07 +07:00
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: {
2024-10-08 14:10:13 +07:00
service: {
include: {
productGroup: true,
work: {
include: {
productOnWork: {
include: {
product: true,
},
},
},
},
},
},
work: true,
2024-10-08 14:10:13 +07:00
product: {
include: { productGroup: true },
},
worker: true,
},
},
paySplit: true,
worker: true,
customerBranch: {
include: { customer: true },
},
_count: {
select: { productServiceList: true },
},
},
where: { id: quotationId, isDebitNote: false },
2024-07-24 13:33:46 +07:00
data: {
...rest,
...price,
quotationStatus: record.quotationStatus === "Expired" ? "Issued" : rest.quotationStatus,
2024-07-24 13:33:46 +07:00
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,
2024-10-03 13:43:05 +07:00
...v,
2024-07-24 13:33:46 +07:00
})),
},
}
: undefined,
productServiceList: list
? {
deleteMany: {},
create: list,
}
: undefined,
updatedByUserId: req.user.sub,
2024-07-24 13:33:46 +07:00
},
});
});
}
2024-07-18 17:23:41 +07:00
@Delete("{quotationId}")
2024-07-24 14:26:23 +07:00
@Security("keycloak", MANAGE_ROLES)
async deleteQuotationById(@Request() req: RequestWithUser, @Path() quotationId: string) {
2024-07-23 16:14:39 +07:00
const record = await prisma.quotation.findUnique({
include: {
registeredBranch: { include: branchRelationPermInclude(req.user) },
},
where: { id: quotationId, isDebitNote: false },
2024-07-23 16:14:39 +07:00
});
if (!record) throw notFoundError("Quotation");
await permissionCheck(req.user, record.registeredBranch);
2024-07-23 16:14:39 +07:00
if (record.status !== Status.CREATED) throw isUsedError("Quotation");
2024-07-23 16:14:39 +07:00
return await prisma.quotation.delete({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: quotationId },
});
}
}
2024-11-18 09:18:00 +07:00
@Route("api/v1/quotation/{quotationId}")
@Tags("Quotation")
export class QuotationActionController extends Controller {
@Post("add-worker")
2024-11-18 09:18:00 +07:00
async addWorker(
@Request() req: RequestWithUser,
@Path() quotationId: string,
@Body()
body: {
workerId: string;
productServiceId: string[];
2024-11-18 09:18:00 +07:00
}[],
) {
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({
2024-11-18 09:18:00 +07:00
include: {
worker: true,
_count: {
select: {
worker: true,
2024-11-18 09:18:00 +07:00
},
},
},
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
}),
]),
);
2024-11-18 09:18:00 +07:00
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",
);
}
2024-11-18 09:18:00 +07:00
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,
2024-11-18 09:18:00 +07:00
});
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 },
2024-11-18 09:18:00 +07:00
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,
})),
},
2024-11-18 09:18:00 +07:00
},
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,
2024-11-18 09:18:00 +07:00
},
});
});
}
2024-07-18 17:23:41 +07:00
}
2024-11-01 13:43:57 +07:00
@Route("api/v1/quotation/{quotationId}/attachment")
@Tags("Quotation")
export class QuotationFileController extends Controller {
2025-01-10 10:19:40 +07:00
async #checkPermission(user: RequestWithUser["user"], id: string) {
2024-11-01 13:43:57 +07:00
const data = await prisma.quotation.findUnique({
include: {
registeredBranch: {
include: branchRelationPermInclude(user),
},
},
where: { id, isDebitNote: false },
2024-11-01 13:43:57 +07:00
});
2025-01-10 10:18:57 +07:00
if (!data) throw notFoundError("Quotation");
2024-11-01 13:43:57 +07:00
await permissionCheck(user, data.registeredBranch);
}
@Get()
@Security("keycloak")
async listAttachment(@Request() req: RequestWithUser, @Path() quotationId: string) {
2025-01-10 10:19:40 +07:00
await this.#checkPermission(req.user, quotationId);
2024-11-01 13:43:57 +07:00
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}")
2024-11-01 17:34:36 +07:00
@Security("keycloak")
2024-11-01 13:43:57 +07:00
async getAttachment(
@Request() req: RequestWithUser,
@Path() quotationId: string,
@Path() name: string,
) {
2025-01-10 10:19:40 +07:00
await this.#checkPermission(req.user, quotationId);
2024-11-01 17:34:36 +07:00
return await getFile(fileLocation.quotation.attachment(quotationId, name));
2024-11-01 13:43:57 +07:00
}
@Put("{name}")
@Security("keycloak", MANAGE_ROLES)
async putAttachment(
@Request() req: RequestWithUser,
@Path() quotationId: string,
@Path() name: string,
) {
2025-01-10 10:19:40 +07:00
await this.#checkPermission(req.user, quotationId);
2024-11-01 13:43:57 +07:00
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,
) {
2025-01-10 10:19:40 +07:00
await this.#checkPermission(req.user, quotationId);
2024-11-01 13:43:57 +07:00
return await deleteFile(fileLocation.quotation.attachment(quotationId, name));
}
}