refactor: update quotation structure and endpoint

This commit is contained in:
Methapon Metanipat 2024-10-01 15:36:45 +07:00
parent 4f4b8df9a3
commit 71ffd895f6
3 changed files with 262 additions and 363 deletions

View file

@ -0,0 +1,59 @@
/*
Warnings:
- You are about to drop the `QuotationService` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `QuotationServiceWork` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `QuotationServiceWorkProduct` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "QuotationService" DROP CONSTRAINT "QuotationService_quotationId_fkey";
-- DropForeignKey
ALTER TABLE "QuotationService" DROP CONSTRAINT "QuotationService_refServiceId_fkey";
-- DropForeignKey
ALTER TABLE "QuotationServiceWork" DROP CONSTRAINT "QuotationServiceWork_serviceId_fkey";
-- DropForeignKey
ALTER TABLE "QuotationServiceWorkProduct" DROP CONSTRAINT "QuotationServiceWorkProduct_productId_fkey";
-- DropForeignKey
ALTER TABLE "QuotationServiceWorkProduct" DROP CONSTRAINT "QuotationServiceWorkProduct_workId_fkey";
-- DropTable
DROP TABLE "QuotationService";
-- DropTable
DROP TABLE "QuotationServiceWork";
-- DropTable
DROP TABLE "QuotationServiceWorkProduct";
-- CreateTable
CREATE TABLE "QuotationProductServiceList" (
"id" TEXT NOT NULL,
"quotationId" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"vat" DOUBLE PRECISION NOT NULL,
"amount" INTEGER NOT NULL,
"discount" DOUBLE PRECISION NOT NULL,
"pricePerUnit" DOUBLE PRECISION NOT NULL,
"productId" TEXT NOT NULL,
"workId" TEXT,
"serviceId" TEXT,
CONSTRAINT "QuotationProductServiceList_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "QuotationProductServiceList" ADD CONSTRAINT "QuotationProductServiceList_quotationId_fkey" FOREIGN KEY ("quotationId") REFERENCES "Quotation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationProductServiceList" ADD CONSTRAINT "QuotationProductServiceList_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationProductServiceList" ADD CONSTRAINT "QuotationProductServiceList_workId_fkey" FOREIGN KEY ("workId") REFERENCES "Work"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationProductServiceList" ADD CONSTRAINT "QuotationProductServiceList_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -931,7 +931,7 @@ model Product {
productGroupId String
workProduct WorkProduct[]
quotationServiceWorkProduct QuotationServiceWorkProduct[]
quotationProductServiceList QuotationProductServiceList[]
createdAt DateTime @default(now())
createdBy User? @relation(name: "ProductCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
@ -955,8 +955,8 @@ model Service {
shared Boolean @default(false)
selectedImage String?
work Work[]
quotationService QuotationService[]
work Work[]
quotationProductServiceList QuotationProductServiceList[]
productGroup ProductGroup @relation(fields: [productGroupId], references: [id], onDelete: Cascade)
productGroupId String
@ -989,7 +989,8 @@ model Work {
updatedBy User? @relation(name: "WorkUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
productOnWork WorkProduct[]
productOnWork WorkProduct[]
quotationProductServiceList QuotationProductServiceList[]
}
model WorkProduct {
@ -1045,10 +1046,10 @@ model Quotation {
workerCount Int
worker QuotationWorker[]
service QuotationService[]
urgent Boolean @default(false)
productServiceList QuotationProductServiceList[]
totalPrice Float
totalDiscount Float
vat Float
@ -1084,47 +1085,24 @@ model QuotationWorker {
quotationId String
}
model QuotationService {
id String @id @default(cuid())
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)
model QuotationProductServiceList {
id String @id @default(cuid())
quotationId String
}
quotation Quotation @relation(fields: [quotationId], references: [id])
model QuotationServiceWork {
id String @id @default(cuid())
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
order Int
vat Float
amount Int
discount Float
pricePerUnit Float
@@id([workId, productId])
productId String
product Product @relation(fields: [productId], references: [id])
workId String?
work Work? @relation(fields: [workId], references: [id])
serviceId String?
service Service? @relation(fields: [serviceId], references: [id])
}

View file

@ -64,28 +64,22 @@ type QuotationCreate = {
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;
}[];
}[];
productServiceList: {
serviceId?: string;
workId?: string;
productId: string;
amount: number;
/**
* @maximum 1
* @minimum 0
*/
discount: number;
pricePerUnit?: number;
/**
* @maximum 1
* @minimum 0
*/
vat?: number;
}[];
};
@ -129,31 +123,22 @@ type QuotationUpdate = {
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;
}[];
}[];
productServiceList?: {
serviceId?: string;
workId?: string;
productId: string;
amount: number;
/**
* @maximum 1
* @minimum 0
*/
discount: number;
pricePerUnit?: number;
/**
* @maximum 1
* @minimum 0
*/
vat?: number;
}[];
};
@ -202,14 +187,11 @@ export class QuotationController extends Controller {
include: {
customerBranch: true,
worker: true,
service: {
productServiceList: {
include: {
_count: { select: { work: true } },
work: {
include: {
_count: { select: { productOnWork: true } },
},
},
product: true,
work: true,
service: true,
},
},
},
@ -231,17 +213,11 @@ export class QuotationController extends Controller {
worker: {
include: { employee: true },
},
service: {
productServiceList: {
include: {
_count: { select: { work: true } },
work: {
include: {
_count: { select: { productOnWork: true } },
productOnWork: {
include: { product: true },
},
},
},
product: true,
work: true,
service: true,
},
},
},
@ -256,45 +232,49 @@ export class QuotationController extends Controller {
@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 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, service, product] = await prisma.$transaction([
prisma.customerBranch.findUnique({
include: {
customer: {
const [customerBranch, employee, product, work, service] = await prisma.$transaction(
async (tx) =>
await Promise.all([
tx.customerBranch.findUnique({
include: {
registeredBranch: {
include: branchRelationPermInclude(req.user),
customer: {
include: {
registeredBranch: {
include: branchRelationPermInclude(req.user),
},
},
},
},
},
},
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 } },
}),
]);
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 (serviceIdList.length !== service.length) throw relationError("Service");
if (productIdList.length !== product.length) throw relationError("Product");
if (existingEmployee.length !== employee.length) throw relationError("Worker");
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 { service: _service, worker: _worker, ...rest } = body;
const { productServiceList: _productServiceList, worker: _worker, ...rest } = body;
return await prisma.$transaction(async (tx) => {
const nonExistEmployee = body.worker.filter((v) => typeof v !== "string");
@ -330,58 +310,6 @@ export class QuotationController extends Controller {
}
}
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 ? VAT_DEFAULT : e.vat) *
100,
) / 100;
return {
...e,
vat: e.vat === undefined ? VAT_DEFAULT : e.vat,
pricePerUnit: currentProduct.price,
};
}),
};
}),
};
});
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const currentDate = new Date().getDate();
@ -397,17 +325,46 @@ export class QuotationController extends Controller {
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,
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,
vatExcluded: 0,
finalPrice: 0,
},
);
return await tx.quotation.create({
include: {
service: {
productServiceList: {
include: {
work: {
include: {
productOnWork: {
include: { product: true },
},
},
},
product: true,
work: true,
service: true,
},
},
paySplit: true,
@ -416,12 +373,13 @@ export class QuotationController extends Controller {
include: { customer: true },
},
_count: {
select: { service: true },
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: {
@ -433,11 +391,6 @@ export class QuotationController extends Controller {
})),
},
},
totalPrice: price.totalPrice,
totalDiscount: price.totalDiscount,
vat: price.totalVat,
vatExcluded: 0,
finalPrice: price.totalPrice - price.totalDiscount,
paySplit: {
createMany: {
data: (rest.paySplit || []).map((v, i) => ({
@ -446,34 +399,7 @@ export class QuotationController extends Controller {
})),
},
},
service: {
create: restructureService.map((a) => ({
code: a.code,
name: a.name,
detail: a.detail,
attributes: a.attributes,
refServiceId: a.id,
work: {
create: a.work.map((b) => ({
order: b.order,
name: b.name,
attributes: b.attributes,
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,
})),
},
},
})),
},
})),
},
productServiceList: { create: list },
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
@ -505,57 +431,53 @@ export class QuotationController extends Controller {
if (!record) throw notFoundError("Quotation");
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 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, service, product] = await prisma.$transaction(
const [customerBranch, employee, product, work, service] = await prisma.$transaction(
async (tx) =>
await Promise.all([
body.customerBranchId
? tx.customerBranch.findFirst({
tx.customerBranch.findUnique({
include: {
customer: {
include: {
customer: {
include: {
registeredBranch: { include: branchRelationPermInclude(req.user) },
},
registeredBranch: {
include: branchRelationPermInclude(req.user),
},
},
where: { id: body.customerBranchId },
})
: null,
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,
},
},
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 (serviceIdList?.length !== service?.length) throw relationError("Service");
if (productIdList?.length !== product?.length) throw relationError("Product");
if (existingEmployee?.length !== employee?.length) throw relationError("Worker");
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 { service: _service, worker: _worker, ...rest } = body;
const { productServiceList: _productServiceList, worker: _worker, ...rest } = body;
return await prisma.$transaction(async (tx) => {
const sortedEmployeeId: string[] = [];
@ -597,69 +519,46 @@ export class QuotationController extends Controller {
}
}
const price = { totalPrice: 0, totalDiscount: 0, totalVat: 0 };
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,
vat: v.vat || VAT_DEFAULT,
}));
const restructureService = body.service?.flatMap((a) => {
const currentService = service?.find((b) => b.id === a.id);
const price = list?.reduce(
(a, c) => {
const price = c.pricePerUnit * c.amount;
const discount = price * c.discount;
const vat = price - discount * c.vat;
if (!currentService) return []; // should not possible
a.totalPrice += price;
a.totalDiscount += discount;
a.vat += vat;
a.finalPrice += price - discount + vat;
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 ? VAT_DEFAULT : e.vat) *
100,
) / 100;
return {
...e,
vat: e.vat === undefined ? VAT_DEFAULT : e.vat,
pricePerUnit: currentProduct.price,
};
}),
};
}),
};
});
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
finalPrice: 0,
},
);
return await tx.quotation.update({
include: {
service: {
productServiceList: {
include: {
work: {
include: {
productOnWork: {
include: { product: true },
},
},
},
product: true,
work: true,
service: true,
},
},
paySplit: true,
@ -668,12 +567,13 @@ export class QuotationController extends Controller {
include: { customer: true },
},
_count: {
select: { service: true },
select: { productServiceList: true },
},
},
where: { id: quotationId },
data: {
...rest,
...price,
statusOrder: +(rest.status === "INACTIVE"),
worker:
sortedEmployeeId.length > 0
@ -689,14 +589,6 @@ export class QuotationController extends Controller {
},
}
: 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: {},
@ -708,40 +600,10 @@ export class QuotationController extends Controller {
},
}
: undefined,
service:
body.service && restructureService
? {
deleteMany: {},
create: restructureService.map((a) => ({
code: a.code,
name: a.name,
detail: a.detail,
attributes: a.attributes,
refServiceId: a.id,
work: {
create: a.work.map((b) => ({
order: b.order,
name: b.name,
attributes: b.attributes,
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,
})),
},
},
})),
},
})),
}
: undefined,
productServiceList: {
deleteMany: {},
create: list,
},
updatedByUserId: req.user.sub,
},
});