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 productGroupId String
workProduct WorkProduct[] workProduct WorkProduct[]
quotationServiceWorkProduct QuotationServiceWorkProduct[] quotationProductServiceList QuotationProductServiceList[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
createdBy User? @relation(name: "ProductCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull) createdBy User? @relation(name: "ProductCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
@ -955,8 +955,8 @@ model Service {
shared Boolean @default(false) shared Boolean @default(false)
selectedImage String? selectedImage String?
work Work[] work Work[]
quotationService QuotationService[] quotationProductServiceList QuotationProductServiceList[]
productGroup ProductGroup @relation(fields: [productGroupId], references: [id], onDelete: Cascade) productGroup ProductGroup @relation(fields: [productGroupId], references: [id], onDelete: Cascade)
productGroupId String productGroupId String
@ -989,7 +989,8 @@ model Work {
updatedBy User? @relation(name: "WorkUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull) updatedBy User? @relation(name: "WorkUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String? updatedByUserId String?
productOnWork WorkProduct[] productOnWork WorkProduct[]
quotationProductServiceList QuotationProductServiceList[]
} }
model WorkProduct { model WorkProduct {
@ -1045,10 +1046,10 @@ model Quotation {
workerCount Int workerCount Int
worker QuotationWorker[] worker QuotationWorker[]
service QuotationService[]
urgent Boolean @default(false) urgent Boolean @default(false)
productServiceList QuotationProductServiceList[]
totalPrice Float totalPrice Float
totalDiscount Float totalDiscount Float
vat Float vat Float
@ -1084,47 +1085,24 @@ model QuotationWorker {
quotationId String quotationId String
} }
model QuotationService { model QuotationProductServiceList {
id String @id @default(cuid()) 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)
quotationId String quotationId String
} quotation Quotation @relation(fields: [quotationId], references: [id])
model QuotationServiceWork { order Int
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
vat Float vat Float
amount Int amount Int
discount Float discount Float
pricePerUnit 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; urgent?: boolean;
service: { productServiceList: {
id: string; serviceId?: string;
// Other fields will come from original data workId?: string;
work: { productId: string;
id: string; amount: number;
// Name field will come from original data /**
excluded?: boolean; * @maximum 1
product: { * @minimum 0
id: string; */
amount: number; discount: number;
/** pricePerUnit?: number;
* @maximum 1 /**
* @minimum 0 * @maximum 1
*/ * @minimum 0
discount: number; */
/** vat?: number;
* @maximum 1
* @minimum 0
*/
vat?: number;
}[];
}[];
}[]; }[];
}; };
@ -129,31 +123,22 @@ type QuotationUpdate = {
urgent?: boolean; urgent?: boolean;
service?: { productServiceList?: {
id: string; serviceId?: string;
// Other fields will come from original data workId?: string;
work: { productId: string;
id: string; amount: number;
excluded?: boolean; /**
// Name field will come from original data * @maximum 1
product: { * @minimum 0
id: string; */
/** discount: number;
* @isInt pricePerUnit?: number;
*/ /**
amount: number; * @maximum 1
/** * @minimum 0
* @maximum 1 */
* @minimum 0 vat?: number;
*/
discount: number;
/**
* @maximum 1
* @minimum 0
*/
vat: number;
}[];
}[];
}[]; }[];
}; };
@ -202,14 +187,11 @@ export class QuotationController extends Controller {
include: { include: {
customerBranch: true, customerBranch: true,
worker: true, worker: true,
service: { productServiceList: {
include: { include: {
_count: { select: { work: true } }, product: true,
work: { work: true,
include: { service: true,
_count: { select: { productOnWork: true } },
},
},
}, },
}, },
}, },
@ -231,17 +213,11 @@ export class QuotationController extends Controller {
worker: { worker: {
include: { employee: true }, include: { employee: true },
}, },
service: { productServiceList: {
include: { include: {
_count: { select: { work: true } }, product: true,
work: { work: true,
include: { service: true,
_count: { select: { productOnWork: true } },
productOnWork: {
include: { product: true },
},
},
},
}, },
}, },
}, },
@ -256,45 +232,49 @@ export class QuotationController extends Controller {
@Post() @Post()
@Security("keycloak", MANAGE_ROLES) @Security("keycloak", MANAGE_ROLES)
async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) { async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) {
const existingEmployee = body.worker.filter((v) => typeof v === "string"); const ids = {
const serviceIdList = body.service.map((v) => v.id); employee: body.worker.filter((v) => typeof v === "string"),
const productIdList = body.service.flatMap((a) => product: body.productServiceList
a.work.flatMap((b) => b.product.map((c) => c.id)), .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(
prisma.customerBranch.findUnique({ async (tx) =>
include: { await Promise.all([
customer: { tx.customerBranch.findUnique({
include: { include: {
registeredBranch: { customer: {
include: branchRelationPermInclude(req.user), include: {
registeredBranch: {
include: branchRelationPermInclude(req.user),
},
},
}, },
}, },
}, where: { id: body.customerBranchId },
}, }),
where: { id: body.customerBranchId }, tx.employee.findMany({ where: { id: { in: ids.employee } } }),
}), tx.product.findMany({ where: { id: { in: ids.product } } }),
prisma.employee.findMany({ ids.work.length ? tx.work.findMany({ where: { id: { in: ids.work } } }) : null,
where: { id: { in: existingEmployee } }, ids.service.length ? tx.service.findMany({ where: { id: { in: ids.service } } }) : null,
}), ]),
prisma.service.findMany({ );
include: { work: true },
where: { id: { in: serviceIdList } },
}),
prisma.product.findMany({
where: { id: { in: productIdList } },
}),
]);
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 (!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); 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) => { return await prisma.$transaction(async (tx) => {
const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); 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 currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1; const currentMonth = new Date().getMonth() + 1;
const currentDate = new Date().getDate(); const currentDate = new Date().getDate();
@ -397,17 +325,46 @@ export class QuotationController extends Controller {
update: { value: { increment: 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,
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({ return await tx.quotation.create({
include: { include: {
service: { productServiceList: {
include: { include: {
work: { product: true,
include: { work: true,
productOnWork: { service: true,
include: { product: true },
},
},
},
}, },
}, },
paySplit: true, paySplit: true,
@ -416,12 +373,13 @@ export class QuotationController extends Controller {
include: { customer: true }, include: { customer: true },
}, },
_count: { _count: {
select: { service: true }, select: { productServiceList: true },
}, },
}, },
data: { data: {
...rest, ...rest,
...price,
statusOrder: +(rest.status === "INACTIVE"), 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")}`, code: `${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${currentDate.toString().padStart(2, "0")}${lastQuotation.value.toString().padStart(4, "0")}`,
worker: { 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: { paySplit: {
createMany: { createMany: {
data: (rest.paySplit || []).map((v, i) => ({ data: (rest.paySplit || []).map((v, i) => ({
@ -446,34 +399,7 @@ export class QuotationController extends Controller {
})), })),
}, },
}, },
service: { productServiceList: { create: list },
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,
})),
},
},
})),
},
})),
},
createdByUserId: req.user.sub, createdByUserId: req.user.sub,
updatedByUserId: req.user.sub, updatedByUserId: req.user.sub,
}, },
@ -505,57 +431,53 @@ export class QuotationController extends Controller {
if (!record) throw notFoundError("Quotation"); if (!record) throw notFoundError("Quotation");
const existingEmployee = body.worker?.filter((v) => typeof v === "string"); const ids = {
const serviceIdList = body.service?.map((v) => v.id); employee: body.worker?.filter((v) => typeof v === "string"),
const productIdList = body.service?.flatMap((a) => product: body.productServiceList
a.work.flatMap((b) => b.product.map((c) => c.id)), ?.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) => async (tx) =>
await Promise.all([ await Promise.all([
body.customerBranchId tx.customerBranch.findUnique({
? tx.customerBranch.findFirst({ include: {
customer: {
include: { include: {
customer: { registeredBranch: {
include: { include: branchRelationPermInclude(req.user),
registeredBranch: { include: branchRelationPermInclude(req.user) },
},
}, },
}, },
where: { id: body.customerBranchId }, },
}) },
: null, where: { id: body.customerBranchId },
body.worker }),
? tx.employee.findMany({ tx.employee.findMany({ where: { id: { in: ids.employee } } }),
where: { id: { in: existingEmployee } }, tx.product.findMany({ where: { id: { in: ids.product } } }),
}) ids.work?.length ? tx.work.findMany({ where: { id: { in: ids.work } } }) : null,
: null, ids.service?.length ? tx.service.findMany({ where: { id: { in: ids.service } } }) : null,
body.service
? tx.service.findMany({
include: { work: true },
where: { id: { in: serviceIdList } },
})
: null,
body.service
? tx.product.findMany({
where: { id: { in: productIdList } },
})
: null,
]), ]),
); );
if (serviceIdList?.length !== service?.length) throw 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 (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); await permissionCheck(req.user, record.customerBranch.customer.registeredBranch);
if (customerBranch && record.customerBranchId !== body.customerBranchId) { if (customerBranch && record.customerBranchId !== body.customerBranchId) {
await permissionCheck(req.user, customerBranch.customer.registeredBranch); 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) => { return await prisma.$transaction(async (tx) => {
const sortedEmployeeId: string[] = []; 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 price = list?.reduce(
const currentService = service?.find((b) => b.id === a.id); (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 { return a;
id: currentService.id, },
name: currentService.name, {
code: currentService.code, totalPrice: 0,
detail: currentService.detail, totalDiscount: 0,
attributes: currentService.attributes as Prisma.JsonObject, vat: 0,
work: a.work.flatMap((c) => { vatExcluded: 0,
if (c.excluded) return []; finalPrice: 0,
},
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 await tx.quotation.update({ return await tx.quotation.update({
include: { include: {
service: { productServiceList: {
include: { include: {
work: { product: true,
include: { work: true,
productOnWork: { service: true,
include: { product: true },
},
},
},
}, },
}, },
paySplit: true, paySplit: true,
@ -668,12 +567,13 @@ export class QuotationController extends Controller {
include: { customer: true }, include: { customer: true },
}, },
_count: { _count: {
select: { service: true }, select: { productServiceList: true },
}, },
}, },
where: { id: quotationId }, where: { id: quotationId },
data: { data: {
...rest, ...rest,
...price,
statusOrder: +(rest.status === "INACTIVE"), statusOrder: +(rest.status === "INACTIVE"),
worker: worker:
sortedEmployeeId.length > 0 sortedEmployeeId.length > 0
@ -689,14 +589,6 @@ export class QuotationController extends Controller {
}, },
} }
: undefined, : 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 paySplit: rest.paySplit
? { ? {
deleteMany: {}, deleteMany: {},
@ -708,40 +600,10 @@ export class QuotationController extends Controller {
}, },
} }
: undefined, : undefined,
productServiceList: {
service: deleteMany: {},
body.service && restructureService create: list,
? { },
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,
updatedByUserId: req.user.sub, updatedByUserId: req.user.sub,
}, },
}); });