feat: update quotation endpoints with permission

This commit is contained in:
Methapon Metanipat 2024-09-26 14:32:44 +07:00
parent 3ee1f5ed0f
commit 56c2f1d5ed
3 changed files with 158 additions and 128 deletions

View file

@ -0,0 +1,25 @@
/*
Warnings:
- Added the required column `actorName` to the `Quotation` table without a default value. This is not possible if the table is not empty.
- Added the required column `contactName` to the `Quotation` table without a default value. This is not possible if the table is not empty.
- Added the required column `contactTel` to the `Quotation` table without a default value. This is not possible if the table is not empty.
- Added the required column `documentReceivePoint` to the `Quotation` table without a default value. This is not possible if the table is not empty.
- Added the required column `dueDate` to the `Quotation` table without a default value. This is not possible if the table is not empty.
- Added the required column `workName` to the `Quotation` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Quotation" DROP CONSTRAINT "Quotation_customerId_fkey";
-- AlterTable
ALTER TABLE "Quotation" ADD COLUMN "actorName" TEXT NOT NULL,
ADD COLUMN "contactName" TEXT NOT NULL,
ADD COLUMN "contactTel" TEXT NOT NULL,
ADD COLUMN "documentReceivePoint" TEXT NOT NULL,
ADD COLUMN "dueDate" DATE NOT NULL,
ADD COLUMN "workName" TEXT NOT NULL,
ALTER COLUMN "customerId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Quotation" ADD CONSTRAINT "Quotation_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1020,16 +1020,21 @@ enum PayCondition {
model Quotation { model Quotation {
id String @id @default(cuid()) id String @id @default(cuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
customerBranchId String customerBranchId String
customerBranch CustomerBranch @relation(fields: [customerBranchId], references: [id]) customerBranch CustomerBranch @relation(fields: [customerBranchId], references: [id])
status Status @default(CREATED) status Status @default(CREATED)
statusOrder Int @default(0) statusOrder Int @default(0)
code String code String
actorName String
workName String
contactName String
contactTel String
documentReceivePoint String
dueDate DateTime @db.Date
date DateTime @default(now()) date DateTime @default(now())
payCondition PayCondition payCondition PayCondition
@ -1051,12 +1056,14 @@ model Quotation {
vatExcluded Float vatExcluded Float
finalPrice Float finalPrice Float
createdAt DateTime @default(now()) createdAt DateTime @default(now())
createdBy User? @relation(name: "QuotationCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull) createdBy User? @relation(name: "QuotationCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String? createdByUserId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "QuotationUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull) updatedBy User? @relation(name: "QuotationUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String? updatedByUserId String?
Customer Customer? @relation(fields: [customerId], references: [id])
customerId String?
} }
model QuotationPaySplit { model QuotationPaySplit {

View file

@ -17,10 +17,24 @@ import { RequestWithUser } from "../interfaces/user";
import prisma from "../db"; import prisma from "../db";
import HttpError from "../interfaces/http-error"; import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status"; import HttpStatus from "../interfaces/http-status";
import {
branchRelationPermInclude,
createPermCheck,
createPermCondition,
} from "../services/permission";
import { isSystem } from "../utils/keycloak";
import { notFoundError, relationError } from "../utils/error";
type QuotationCreate = { type QuotationCreate = {
status?: Status; status?: Status;
actorName: string;
workName: string;
contactName: string;
contactTel: string;
documentReceivePoint: string;
dueDate: Date;
payCondition: PayCondition; payCondition: PayCondition;
paySplitCount?: number; paySplitCount?: number;
@ -47,7 +61,6 @@ type QuotationCreate = {
)[]; )[];
customerBranchId: string; customerBranchId: string;
customerId: string;
urgent?: boolean; urgent?: boolean;
@ -79,6 +92,13 @@ type QuotationCreate = {
type QuotationUpdate = { type QuotationUpdate = {
status?: "ACTIVE" | "INACTIVE"; status?: "ACTIVE" | "INACTIVE";
actorName?: string;
workName?: string;
contactName?: string;
contactTel?: string;
documentReceivePoint?: string;
dueDate?: Date;
payCondition?: PayCondition; payCondition?: PayCondition;
paySplitCount?: number; paySplitCount?: number;
@ -106,7 +126,6 @@ type QuotationUpdate = {
)[]; )[];
customerBranchId?: string; customerBranchId?: string;
customerId?: string;
urgent?: boolean; urgent?: boolean;
@ -142,24 +161,40 @@ const MANAGE_ROLES = [
"system", "system",
"head_of_admin", "head_of_admin",
"admin", "admin",
"branch_manager",
"head_of_account", "head_of_account",
"account", "account",
"head_of_sale",
"sale",
]; ];
const VAT_DEFAULT = 0.07;
function globalAllow(user: RequestWithUser["user"]) { function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "admin", "branch_manager", "head_of_account"]; const allowList = ["system", "head_of_admin", "head_of_account", "head_of_sale"];
return allowList.some((v) => user.roles?.includes(v)); return allowList.some((v) => user.roles?.includes(v));
} }
const permissionCheck = createPermCheck(globalAllow);
const permissionCond = createPermCondition(globalAllow);
@Route("/api/v1/quotation") @Route("/api/v1/quotation")
@Tags("Quotation") @Tags("Quotation")
export class QuotationController extends Controller { export class QuotationController extends Controller {
@Get() @Get()
@Security("keycloak") @Security("keycloak")
async getQuotationList(@Query() page: number = 1, @Query() pageSize: number = 30) { async getQuotationList(
@Request() req: RequestWithUser,
@Query() page: number = 1,
@Query() pageSize: number = 30,
) {
const [result, total] = await prisma.$transaction([ const [result, total] = await prisma.$transaction([
prisma.quotation.findMany({ prisma.quotation.findMany({
where: {
customerBranch: {
customer: {
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
},
},
},
include: { include: {
worker: true, worker: true,
service: { service: {
@ -168,9 +203,6 @@ export class QuotationController extends Controller {
work: { work: {
include: { include: {
_count: { select: { productOnWork: true } }, _count: { select: { productOnWork: true } },
productOnWork: {
include: { product: true },
},
}, },
}, },
}, },
@ -206,9 +238,7 @@ export class QuotationController extends Controller {
where: { id: quotationId }, where: { id: quotationId },
}); });
if (!record) { if (!record) throw notFoundError("Quotation");
throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound");
}
return record; return record;
} }
@ -222,12 +252,17 @@ export class QuotationController extends Controller {
a.work.flatMap((b) => b.product.map((c) => c.id)), a.work.flatMap((b) => b.product.map((c) => c.id)),
); );
const [customer, customerBranch, employee, service, product] = await prisma.$transaction([ const [customerBranch, employee, service, product] = await prisma.$transaction([
prisma.customer.findUnique({
where: { id: body.customerId },
}),
prisma.customerBranch.findUnique({ prisma.customerBranch.findUnique({
include: { customer: true }, include: {
customer: {
include: {
registeredBranch: {
include: branchRelationPermInclude(req.user),
},
},
},
},
where: { id: body.customerBranchId }, where: { id: body.customerBranchId },
}), }),
prisma.employee.findMany({ prisma.employee.findMany({
@ -242,45 +277,12 @@ export class QuotationController extends Controller {
}), }),
]); ]);
if (serviceIdList.length !== service.length) { if (serviceIdList.length !== service.length) throw relationError("Service");
throw new HttpError( if (productIdList.length !== product.length) throw relationError("Product");
HttpStatus.BAD_REQUEST, if (existingEmployee.length !== employee.length) throw relationError("Worker");
"Some service cannot be found.", if (!customerBranch) throw relationError("Customer Branch");
"relationServiceNotFound",
); await permissionCheck(req.user, customerBranch.customer.registeredBranch);
}
if (productIdList.length !== product.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Some product cannot be found.",
"relationProductNotFound",
);
}
if (existingEmployee.length !== employee.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Some worker(employee) cannot be found.",
"relationWorkerNotFound",
);
}
if (!customer)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer cannot be found.",
"relationCustomerNotFound",
);
if (!customerBranch)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer Branch cannot be found.",
"relationCustomerBranchNotFound",
);
if (customerBranch.customerId !== customer.id)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer conflict with customer branch.",
"customerConflictCustomerBranch",
);
const { service: _service, worker: _worker, ...rest } = body; const { service: _service, worker: _worker, ...rest } = body;
@ -292,7 +294,7 @@ export class QuotationController extends Controller {
}, },
create: { create: {
key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
value: 1, value: nonExistEmployee.length,
}, },
update: { value: { increment: nonExistEmployee.length } }, update: { value: { increment: nonExistEmployee.length } },
}); });
@ -301,7 +303,7 @@ export class QuotationController extends Controller {
tx.employee.create({ tx.employee.create({
data: { data: {
...v, ...v,
code: `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value + i}`.padStart(7, "0")}`, code: `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`,
customerBranchId: customerBranch.id, customerBranchId: customerBranch.id,
}, },
}), }),
@ -355,13 +357,13 @@ export class QuotationController extends Controller {
Math.round( Math.round(
(currentProduct.price * e.amount - (currentProduct.price * e.amount -
currentProduct.price * e.amount * e.discount) * currentProduct.price * e.amount * e.discount) *
(e.vat === undefined ? 0.07 : e.vat) * (e.vat === undefined ? VAT_DEFAULT : e.vat) *
100, 100,
) / 100; ) / 100;
return { return {
...e, ...e,
vat: e.vat === undefined ? 0.07 : e.vat, vat: e.vat === undefined ? VAT_DEFAULT : e.vat,
pricePerUnit: currentProduct.price, pricePerUnit: currentProduct.price,
}; };
}), }),
@ -477,13 +479,21 @@ export class QuotationController extends Controller {
@Body() body: QuotationUpdate, @Body() body: QuotationUpdate,
) { ) {
const record = await prisma.quotation.findUnique({ const record = await prisma.quotation.findUnique({
include: { customer: true }, include: {
customerBranch: {
include: {
customer: {
include: {
registeredBranch: { include: branchRelationPermInclude(req.user) },
},
},
},
},
},
where: { id: quotationId }, where: { id: quotationId },
}); });
if (!record) { if (!record) throw notFoundError("Quotation");
throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound");
}
const existingEmployee = body.worker?.filter((v) => typeof v === "string"); const existingEmployee = body.worker?.filter((v) => typeof v === "string");
const serviceIdList = body.service?.map((v) => v.id); const serviceIdList = body.service?.map((v) => v.id);
@ -491,16 +501,21 @@ export class QuotationController extends Controller {
a.work.flatMap((b) => b.product.map((c) => c.id)), a.work.flatMap((b) => b.product.map((c) => c.id)),
); );
const [customer, customerBranch, employee, service, product] = await prisma.$transaction( const [customerBranch, employee, service, product] = await prisma.$transaction(
async (tx) => async (tx) =>
await Promise.all([ await Promise.all([
tx.customer.findFirst({ body.customerBranchId
where: { id: body.customerId }, ? tx.customerBranch.findFirst({
}), include: {
tx.customerBranch.findFirst({ customer: {
include: { customer: true }, include: {
where: { id: body.customerBranchId }, registeredBranch: { include: branchRelationPermInclude(req.user) },
}), },
},
},
where: { id: body.customerBranchId },
})
: null,
body.worker body.worker
? tx.employee.findMany({ ? tx.employee.findMany({
where: { id: { in: existingEmployee } }, where: { id: { in: existingEmployee } },
@ -520,59 +535,32 @@ export class QuotationController extends Controller {
]), ]),
); );
if (serviceIdList?.length !== service?.length) { if (serviceIdList?.length !== service?.length) throw relationError("Service");
throw new HttpError( if (productIdList?.length !== product?.length) throw relationError("Product");
HttpStatus.BAD_REQUEST, if (existingEmployee?.length !== employee?.length) throw relationError("Worker");
"Some service cannot be found.", if (body.customerBranchId && !customerBranch) throw relationError("Customer Branch");
"relationServiceNotFound",
); await permissionCheck(req.user, record.customerBranch.customer.registeredBranch);
if (customerBranch && record.customerBranchId !== body.customerBranchId) {
await permissionCheck(req.user, customerBranch.customer.registeredBranch);
} }
if (productIdList?.length !== product?.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Some product cannot be found.",
"relationProductNotFound",
);
}
if (existingEmployee?.length !== employee?.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Some worker(employee) cannot be found.",
"relationWorkerNotFound",
);
}
if (!customer)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer cannot be found.",
"relationCustomerNotFound",
);
if (!customerBranch)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer Branch cannot be found.",
"relationCustomerBranchNotFound",
);
if (customerBranch.customerId !== customer.id)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer conflict with customer branch.",
"customerConflictCustomerBranch",
);
const { service: _service, worker: _worker, ...rest } = body; const { service: _service, worker: _worker, ...rest } = body;
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const sortedEmployeeId: string[] = []; const sortedEmployeeId: string[] = [];
const branchId = (customerBranch || record.customerBranch).id;
const branchCode = (customerBranch || record.customerBranch).code;
if (body.worker) { if (body.worker) {
const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); const nonExistEmployee = body.worker.filter((v) => typeof v !== "string");
const lastEmployee = await tx.runningNo.upsert({ const lastEmployee = await tx.runningNo.upsert({
where: { where: {
key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, key: `EMPLOYEE_${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
}, },
create: { create: {
key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, key: `EMPLOYEE_${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
value: 1, value: 1,
}, },
update: { value: { increment: nonExistEmployee.length } }, update: { value: { increment: nonExistEmployee.length } },
@ -582,8 +570,8 @@ export class QuotationController extends Controller {
tx.employee.create({ tx.employee.create({
data: { data: {
...v, ...v,
code: `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`, code: `${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`,
customerBranchId: customerBranch.id, customerBranchId: branchId,
}, },
}), }),
), ),
@ -636,13 +624,13 @@ export class QuotationController extends Controller {
Math.round( Math.round(
(currentProduct.price * e.amount - (currentProduct.price * e.amount -
currentProduct.price * e.amount * e.discount) * currentProduct.price * e.amount * e.discount) *
(e.vat === undefined ? 0.07 : e.vat) * (e.vat === undefined ? VAT_DEFAULT : e.vat) *
100, 100,
) / 100; ) / 100;
return { return {
...e, ...e,
vat: e.vat === undefined ? 0.07 : e.vat, vat: e.vat === undefined ? VAT_DEFAULT : e.vat,
pricePerUnit: currentProduct.price, pricePerUnit: currentProduct.price,
}; };
}), }),
@ -677,7 +665,6 @@ export class QuotationController extends Controller {
data: { data: {
...rest, ...rest,
statusOrder: +(rest.status === "INACTIVE"), statusOrder: +(rest.status === "INACTIVE"),
code: "",
worker: worker:
sortedEmployeeId.length > 0 sortedEmployeeId.length > 0
? { ? {
@ -753,14 +740,25 @@ export class QuotationController extends Controller {
@Delete("{quotationId}") @Delete("{quotationId}")
@Security("keycloak", MANAGE_ROLES) @Security("keycloak", MANAGE_ROLES)
async deleteQuotationById(@Path() quotationId: string) { async deleteQuotationById(@Request() req: RequestWithUser, @Path() quotationId: string) {
const record = await prisma.quotation.findUnique({ const record = await prisma.quotation.findUnique({
include: {
customerBranch: {
include: {
customer: {
include: {
registeredBranch: { include: branchRelationPermInclude(req.user) },
},
},
},
},
},
where: { id: quotationId }, where: { id: quotationId },
}); });
if (!record) { if (!record) throw notFoundError("Quotation");
throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound");
} await permissionCheck(req.user, record.customerBranch.customer.registeredBranch);
if (record.status !== Status.CREATED) { if (record.status !== Status.CREATED) {
throw new HttpError(HttpStatus.FORBIDDEN, "Quotation is in used.", "quotationInUsed"); throw new HttpError(HttpStatus.FORBIDDEN, "Quotation is in used.", "quotationInUsed");