From f673f2c953be74a9a223569b88bef7c464b92439 Mon Sep 17 00:00:00 2001 From: Methapon Metanipat <162551568+Methapon-Frappet@users.noreply.github.com> Date: Fri, 20 Dec 2024 14:22:55 +0700 Subject: [PATCH] feat: credit note (#8) * feat: add db table and structure * chore: migration * createCreditNote * add check conditions quotationId before create credit note and add fn delete data * feat: add query list of credit note * feat: add get stats of credit ntoe * update * feat: add get by id * add permission * delete console log * chore: cleanup --------- Co-authored-by: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Co-authored-by: Kanjana --- .../20241218094507_credit_note/migration.sql | 45 +++ prisma/schema.prisma | 26 +- src/controllers/08-credit-note-controller.ts | 355 ++++++++++++++++++ tsoa.json | 4 +- 4 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20241218094507_credit_note/migration.sql create mode 100644 src/controllers/08-credit-note-controller.ts diff --git a/prisma/migrations/20241218094507_credit_note/migration.sql b/prisma/migrations/20241218094507_credit_note/migration.sql new file mode 100644 index 0000000..efc3b19 --- /dev/null +++ b/prisma/migrations/20241218094507_credit_note/migration.sql @@ -0,0 +1,45 @@ +-- AlterTable +ALTER TABLE "RequestWork" ADD COLUMN "creditNoteId" TEXT; + +-- AlterTable +ALTER TABLE "_NotificationToNotificationGroup" ADD CONSTRAINT "_NotificationToNotificationGroup_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_NotificationToNotificationGroup_AB_unique"; + +-- AlterTable +ALTER TABLE "_NotificationToUser" ADD CONSTRAINT "_NotificationToUser_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_NotificationToUser_AB_unique"; + +-- AlterTable +ALTER TABLE "_UserToUserResponsibleArea" ADD CONSTRAINT "_UserToUserResponsibleArea_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_UserToUserResponsibleArea_AB_unique"; + +-- CreateTable +CREATE TABLE "CreditNote" ( + "id" TEXT NOT NULL, + "quotationId" TEXT NOT NULL, + + CONSTRAINT "CreditNote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DebitNote" ( + "id" TEXT NOT NULL, + "quotationId" TEXT NOT NULL, + + CONSTRAINT "DebitNote_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "RequestWork" ADD CONSTRAINT "RequestWork_creditNoteId_fkey" FOREIGN KEY ("creditNoteId") REFERENCES "CreditNote"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CreditNote" ADD CONSTRAINT "CreditNote_quotationId_fkey" FOREIGN KEY ("quotationId") REFERENCES "Quotation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DebitNote" ADD CONSTRAINT "DebitNote_quotationId_fkey" FOREIGN KEY ("quotationId") REFERENCES "Quotation"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ccf354c..5546d6a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1283,7 +1283,9 @@ model Quotation { updatedBy User? @relation(name: "QuotationUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull) updatedByUserId String? - invoice Invoice[] + invoice Invoice[] + creditNote CreditNote[] + debitNote DebitNote[] } model QuotationPaySplit { @@ -1449,6 +1451,9 @@ model RequestWork { attributes Json? stepStatus RequestWorkStepStatus[] + + creditNote CreditNote? @relation(fields: [creditNoteId], references: [id]) + creditNoteId String? } model RequestWorkStepStatus { @@ -1555,3 +1560,22 @@ model UserTask { user User @relation(fields: [userId], references: [id]) userId String } + +model CreditNote { + id String @id @default(cuid()) + + quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade) + quotationId String + + // NOTE: only status cancel + requestWork RequestWork[] +} + +model DebitNote { + id String @id @default(cuid()) + + quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade) + quotationId String + + // NOTE: create quotation but with flag debit note? +} diff --git a/src/controllers/08-credit-note-controller.ts b/src/controllers/08-credit-note-controller.ts new file mode 100644 index 0000000..31ec519 --- /dev/null +++ b/src/controllers/08-credit-note-controller.ts @@ -0,0 +1,355 @@ +import { + Body, + Controller, + Delete, + Get, + Path, + Post, + Put, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; + +// import { Prisma } from "@prisma/client"; +import prisma from "../db"; + +import { RequestWithUser } from "../interfaces/user"; +import { + branchRelationPermInclude, + createPermCheck, + createPermCondition, +} from "../services/permission"; +import HttpError from "../interfaces/http-error"; +import HttpStatus from "../interfaces/http-status"; +import { notFoundError } from "../utils/error"; +import { Prisma } from "@prisma/client"; +import { queryOrNot } from "../utils/relation"; +import { RequestWorkStatus } from "../generated/kysely/types"; + +const MANAGE_ROLES = [ + "system", + "head_of_admin", + "admin", + "head_of_accountant", + "accountant", + "head_of_sale", + "sale", +]; + +function globalAllow(user: RequestWithUser["user"]) { + const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"]; + return allowList.some((v) => user.roles?.includes(v)); +} + +// NOTE: permission condition/check in requestWork -> requestData -> quotation -> registeredBranch +const permissionCond = createPermCondition(globalAllow); +const permissionCondCompany = createPermCondition((_) => true); +const permissionCheck = createPermCheck(globalAllow); +const permissionCheckCompany = createPermCheck((_) => true); + +type CreditNoteCreate = { + requestWorkId: string[]; + quotationId: string; +}; +type CreditNoteUpdate = { + requestWorkId: string[]; + quotationId: string; +}; + +@Route("api/v1/credit-note") +@Tags("Credit Note") +export class CreditNoteController extends Controller { + @Get("stats") + @Security("keycloak") + async getCreditNoteStats(@Request() req: RequestWithUser, @Query() quotationId: string) { + const where = { + requestWork: { + some: { + request: { + quotationId, + quotation: { + registeredBranch: { OR: permissionCondCompany(req.user) }, + }, + }, + }, + }, + } satisfies Prisma.CreditNoteWhereInput; + return await prisma.creditNote.count({ where }); + } + + @Get() + @Security("keycloak") + async getCreditNoteList( + @Request() req: RequestWithUser, + @Query() page: number = 1, + @Query() pageSize: number = 30, + @Query() query: string = "", + @Query() quotationId?: string, + ) { + return await this.getCreditNoteListByCriteria(req, page, pageSize, query, quotationId); + } + + // NOTE: only when needed or else remove this and implement in getCreditNoteList + @Post("list") + @Security("keycloak") + async getCreditNoteListByCriteria( + @Request() req: RequestWithUser, + @Query() page: number = 1, + @Query() pageSize: number = 30, + @Query() query: string = "", + @Query() quotationId?: string, + @Body() body?: {}, + ) { + const where = { + OR: queryOrNot(query, [ + { + requestWork: { + some: { + request: { + OR: queryOrNot(query, [ + { quotation: { code: { contains: query, mode: "insensitive" } } }, + { quotation: { workName: { contains: query } } }, + { + quotation: { + customerBranch: { + OR: [ + { code: { contains: query, mode: "insensitive" } }, + { customerName: { contains: query } }, + { firstName: { contains: query } }, + { firstNameEN: { contains: query } }, + { lastName: { contains: query } }, + { lastNameEN: { contains: query } }, + ], + }, + }, + employee: { + OR: [ + { + employeePassport: { + some: { number: { contains: query } }, + }, + }, + { code: { contains: query, mode: "insensitive" } }, + { firstName: { contains: query } }, + { firstNameEN: { contains: query } }, + { lastName: { contains: query } }, + { lastNameEN: { contains: query } }, + ], + }, + }, + ]), + }, + }, + }, + }, + ]), + requestWork: { + some: { + request: { + quotationId, + quotation: { + registeredBranch: { OR: permissionCondCompany(req.user) }, + }, + }, + }, + }, + } satisfies Prisma.CreditNoteWhereInput; + + const [result, total] = await prisma.$transaction([ + prisma.creditNote.findMany({ + where, + include: { + requestWork: { + include: { request: true }, + }, + }, + }), + prisma.creditNote.count({ where }), + ]); + + return { result, page, pageSize, total }; + } + + @Get("{creditNoteId}") + @Security("keycloak") + async getCreditNote(@Request() req: RequestWithUser, @Path() creditNoteId: string) { + const where = { + id: creditNoteId, + requestWork: { + some: { + request: { + quotation: { + registeredBranch: { OR: permissionCondCompany(req.user) }, + }, + }, + }, + }, + } satisfies Prisma.CreditNoteWhereInput; + return prisma.creditNote.findFirst({ + where, + include: { + requestWork: { + include: { request: true }, + }, + }, + }); + } + + @Post() + @Security("keycloak", MANAGE_ROLES) + async createCreditNote(@Request() req: RequestWithUser, @Body() body: CreditNoteCreate) { + const requestWork = await prisma.requestWork.findMany({ + where: { + request: { + quotation: { + id: body.quotationId, + }, + }, + stepStatus: { + some: { + workStatus: "Canceled", + }, + }, + id: { in: body.requestWorkId }, + }, + include: { + request: { + include: { + quotation: { + include: { + registeredBranch: { include: branchRelationPermInclude(req.user) }, + }, + }, + }, + }, + }, + }); + + if (requestWork.length !== body.requestWorkId.length) { + throw new HttpError(HttpStatus.BAD_REQUEST, "Not Match", "reqNotMet"); + } + + await Promise.all( + requestWork.map((item) => permissionCheck(req.user, item.request.quotation.registeredBranch)), + ); + + const record = await prisma.creditNote.create({ + include: { + requestWork: { + include: { + request: true, + }, + }, + quotation: true, + }, + data: { + requestWork: { + connect: body.requestWorkId.map((v) => ({ + id: v, + })), + }, + quotationId: body.quotationId, + }, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Put("{creditNoteId}") + @Security("keycloak", MANAGE_ROLES) + async updateCreditNote( + @Request() req: RequestWithUser, + @Path() creditNoteId: string, + @Body() body: CreditNoteUpdate, + ) { + const creditNoteData = await prisma.creditNote.findFirst({ + where: { id: creditNoteId }, + include: { + requestWork: true, + quotation: { + include: { + registeredBranch: { include: branchRelationPermInclude(req.user) }, + }, + }, + }, + }); + + if (!creditNoteData) throw notFoundError("Credit Note"); + await permissionCheck(req.user, creditNoteData.quotation.registeredBranch); + + const requestWork = await prisma.requestWork.findMany({ + where: { + request: { + quotation: { + id: body.quotationId, + }, + }, + stepStatus: { + some: { + workStatus: RequestWorkStatus.Canceled, + }, + }, + id: { in: body.requestWorkId }, + }, + }); + + if (requestWork.length !== body.requestWorkId.length) { + throw new HttpError(HttpStatus.BAD_REQUEST, "Not Match", "reqNotMet"); + } + + const record = await prisma.creditNote.update({ + where: { + id: creditNoteId, + }, + include: { + requestWork: { + include: { + request: true, + }, + }, + quotation: true, + }, + data: { + requestWork: { + disconnect: creditNoteData.requestWork + .map((item) => ({ + id: item.id, + })) + .filter((data) => !body.requestWorkId.find((item) => (item = data.id))), + connect: body.requestWorkId.map((v) => ({ + id: v, + })), + }, + quotationId: body.quotationId, + }, + }); + + return record; + } + + @Delete("{creditNoteId}") + @Security("keycloak", MANAGE_ROLES) + async deleteCreditNote(@Request() req: RequestWithUser, @Path() creditNoteId: string) { + const record = await prisma.creditNote.findFirst({ + where: { + id: creditNoteId, + }, + include: { + quotation: { + include: { + registeredBranch: { include: branchRelationPermInclude(req.user) }, + }, + }, + }, + }); + + if (!record) throw notFoundError("Credit Note"); + await permissionCheck(req.user, record.quotation.registeredBranch); + return await prisma.creditNote.delete({ where: { id: creditNoteId } }); + } +} diff --git a/tsoa.json b/tsoa.json index 7c98d13..09db573 100644 --- a/tsoa.json +++ b/tsoa.json @@ -51,7 +51,9 @@ { "name": "Receipt" }, { "name": "Request List" }, { "name": "Task Order" }, - { "name": "User Task Order" } + { "name": "User Task Order" }, + { "name": "Credit Note" }, + { "name": "Debit Note" } ] } },