diff --git a/prisma/migrations/20240926073213_quotation_fields_update/migration.sql b/prisma/migrations/20240926073213_quotation_fields_update/migration.sql new file mode 100644 index 0000000..c486e71 --- /dev/null +++ b/prisma/migrations/20240926073213_quotation_fields_update/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aef7e6a..fd66618 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1020,16 +1020,21 @@ enum PayCondition { model Quotation { id String @id @default(cuid()) - customerId String - customer Customer @relation(fields: [customerId], references: [id]) - customerBranchId String customerBranch CustomerBranch @relation(fields: [customerBranchId], references: [id]) status Status @default(CREATED) 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()) payCondition PayCondition @@ -1051,12 +1056,14 @@ model Quotation { vatExcluded Float finalPrice Float - createdAt DateTime @default(now()) - createdBy User? @relation(name: "QuotationCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + createdBy User? @relation(name: "QuotationCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull) createdByUserId String? - updatedAt DateTime @updatedAt - updatedBy User? @relation(name: "QuotationUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull) + updatedAt DateTime @updatedAt + updatedBy User? @relation(name: "QuotationUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull) updatedByUserId String? + Customer Customer? @relation(fields: [customerId], references: [id]) + customerId String? } model QuotationPaySplit { diff --git a/src/controllers/05-quotation-controller.ts b/src/controllers/05-quotation-controller.ts index 12684a9..8d0e51a 100644 --- a/src/controllers/05-quotation-controller.ts +++ b/src/controllers/05-quotation-controller.ts @@ -17,10 +17,24 @@ import { RequestWithUser } from "../interfaces/user"; import prisma from "../db"; import HttpError from "../interfaces/http-error"; 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 = { status?: Status; + actorName: string; + workName: string; + contactName: string; + contactTel: string; + documentReceivePoint: string; + dueDate: Date; + payCondition: PayCondition; paySplitCount?: number; @@ -47,7 +61,6 @@ type QuotationCreate = { )[]; customerBranchId: string; - customerId: string; urgent?: boolean; @@ -79,6 +92,13 @@ type QuotationCreate = { type QuotationUpdate = { status?: "ACTIVE" | "INACTIVE"; + actorName?: string; + workName?: string; + contactName?: string; + contactTel?: string; + documentReceivePoint?: string; + dueDate?: Date; + payCondition?: PayCondition; paySplitCount?: number; @@ -106,7 +126,6 @@ type QuotationUpdate = { )[]; customerBranchId?: string; - customerId?: string; urgent?: boolean; @@ -142,24 +161,40 @@ const MANAGE_ROLES = [ "system", "head_of_admin", "admin", - "branch_manager", "head_of_account", "account", + "head_of_sale", + "sale", ]; +const VAT_DEFAULT = 0.07; 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)); } +const permissionCheck = createPermCheck(globalAllow); +const permissionCond = createPermCondition(globalAllow); + @Route("/api/v1/quotation") @Tags("Quotation") export class QuotationController extends Controller { @Get() @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([ prisma.quotation.findMany({ + where: { + customerBranch: { + customer: { + registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, + }, + }, + }, include: { worker: true, service: { @@ -168,9 +203,6 @@ export class QuotationController extends Controller { work: { include: { _count: { select: { productOnWork: true } }, - productOnWork: { - include: { product: true }, - }, }, }, }, @@ -206,9 +238,7 @@ export class QuotationController extends Controller { where: { id: quotationId }, }); - if (!record) { - throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound"); - } + if (!record) throw notFoundError("Quotation"); return record; } @@ -222,12 +252,17 @@ export class QuotationController extends Controller { a.work.flatMap((b) => b.product.map((c) => c.id)), ); - const [customer, customerBranch, employee, service, product] = await prisma.$transaction([ - prisma.customer.findUnique({ - where: { id: body.customerId }, - }), + const [customerBranch, employee, service, product] = await prisma.$transaction([ prisma.customerBranch.findUnique({ - include: { customer: true }, + include: { + customer: { + include: { + registeredBranch: { + include: branchRelationPermInclude(req.user), + }, + }, + }, + }, where: { id: body.customerBranchId }, }), prisma.employee.findMany({ @@ -242,45 +277,12 @@ export class QuotationController extends Controller { }), ]); - if (serviceIdList.length !== service.length) { - throw new HttpError( - HttpStatus.BAD_REQUEST, - "Some service cannot be found.", - "relationServiceNotFound", - ); - } - 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", - ); + 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"); + + await permissionCheck(req.user, customerBranch.customer.registeredBranch); const { service: _service, worker: _worker, ...rest } = body; @@ -292,7 +294,7 @@ export class QuotationController extends Controller { }, create: { key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, - value: 1, + value: nonExistEmployee.length, }, update: { value: { increment: nonExistEmployee.length } }, }); @@ -301,7 +303,7 @@ export class QuotationController extends Controller { tx.employee.create({ data: { ...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, }, }), @@ -355,13 +357,13 @@ export class QuotationController extends Controller { Math.round( (currentProduct.price * e.amount - currentProduct.price * e.amount * e.discount) * - (e.vat === undefined ? 0.07 : e.vat) * + (e.vat === undefined ? VAT_DEFAULT : e.vat) * 100, ) / 100; return { ...e, - vat: e.vat === undefined ? 0.07 : e.vat, + vat: e.vat === undefined ? VAT_DEFAULT : e.vat, pricePerUnit: currentProduct.price, }; }), @@ -477,13 +479,21 @@ export class QuotationController extends Controller { @Body() body: QuotationUpdate, ) { const record = await prisma.quotation.findUnique({ - include: { customer: true }, + include: { + customerBranch: { + include: { + customer: { + include: { + registeredBranch: { include: branchRelationPermInclude(req.user) }, + }, + }, + }, + }, + }, where: { id: quotationId }, }); - if (!record) { - throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound"); - } + if (!record) throw notFoundError("Quotation"); const existingEmployee = body.worker?.filter((v) => typeof v === "string"); 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)), ); - const [customer, customerBranch, employee, service, product] = await prisma.$transaction( + const [customerBranch, employee, service, product] = await prisma.$transaction( async (tx) => await Promise.all([ - tx.customer.findFirst({ - where: { id: body.customerId }, - }), - tx.customerBranch.findFirst({ - include: { customer: true }, - where: { id: body.customerBranchId }, - }), + body.customerBranchId + ? tx.customerBranch.findFirst({ + include: { + customer: { + include: { + registeredBranch: { include: branchRelationPermInclude(req.user) }, + }, + }, + }, + where: { id: body.customerBranchId }, + }) + : null, body.worker ? tx.employee.findMany({ where: { id: { in: existingEmployee } }, @@ -520,59 +535,32 @@ export class QuotationController extends Controller { ]), ); - if (serviceIdList?.length !== service?.length) { - throw new HttpError( - HttpStatus.BAD_REQUEST, - "Some service cannot be found.", - "relationServiceNotFound", - ); + 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"); + + 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; return await prisma.$transaction(async (tx) => { const sortedEmployeeId: string[] = []; + const branchId = (customerBranch || record.customerBranch).id; + const branchCode = (customerBranch || record.customerBranch).code; + if (body.worker) { const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); const lastEmployee = await tx.runningNo.upsert({ where: { - key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, + key: `EMPLOYEE_${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, }, 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, }, update: { value: { increment: nonExistEmployee.length } }, @@ -582,8 +570,8 @@ export class QuotationController extends Controller { tx.employee.create({ data: { ...v, - code: `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`, - customerBranchId: customerBranch.id, + code: `${branchCode}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`, + customerBranchId: branchId, }, }), ), @@ -636,13 +624,13 @@ export class QuotationController extends Controller { Math.round( (currentProduct.price * e.amount - currentProduct.price * e.amount * e.discount) * - (e.vat === undefined ? 0.07 : e.vat) * + (e.vat === undefined ? VAT_DEFAULT : e.vat) * 100, ) / 100; return { ...e, - vat: e.vat === undefined ? 0.07 : e.vat, + vat: e.vat === undefined ? VAT_DEFAULT : e.vat, pricePerUnit: currentProduct.price, }; }), @@ -677,7 +665,6 @@ export class QuotationController extends Controller { data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), - code: "", worker: sortedEmployeeId.length > 0 ? { @@ -753,14 +740,25 @@ export class QuotationController extends Controller { @Delete("{quotationId}") @Security("keycloak", MANAGE_ROLES) - async deleteQuotationById(@Path() quotationId: string) { + async deleteQuotationById(@Request() req: RequestWithUser, @Path() quotationId: string) { const record = await prisma.quotation.findUnique({ + include: { + customerBranch: { + include: { + customer: { + include: { + registeredBranch: { include: branchRelationPermInclude(req.user) }, + }, + }, + }, + }, + }, where: { id: quotationId }, }); - if (!record) { - throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound"); - } + if (!record) throw notFoundError("Quotation"); + + await permissionCheck(req.user, record.customerBranch.customer.registeredBranch); if (record.status !== Status.CREATED) { throw new HttpError(HttpStatus.FORBIDDEN, "Quotation is in used.", "quotationInUsed");