diff --git a/prisma/migrations/20250106045511_add_credit_note_status/migration.sql b/prisma/migrations/20250106045511_add_credit_note_status/migration.sql new file mode 100644 index 0000000..8929904 --- /dev/null +++ b/prisma/migrations/20250106045511_add_credit_note_status/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "CreditNoteStatus" AS ENUM ('Pending', 'Success'); + +-- AlterTable +ALTER TABLE "CreditNote" ADD COLUMN "creditNoteStatus" "CreditNoteStatus"; diff --git a/prisma/migrations/20250106045533_default_credit_note_status/migration.sql b/prisma/migrations/20250106045533_default_credit_note_status/migration.sql new file mode 100644 index 0000000..6a8551a --- /dev/null +++ b/prisma/migrations/20250106045533_default_credit_note_status/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `creditNoteStatus` on table `CreditNote` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "CreditNote" ALTER COLUMN "creditNoteStatus" SET NOT NULL, +ALTER COLUMN "creditNoteStatus" SET DEFAULT 'Pending'; diff --git a/prisma/migrations/20250106080031_add_value_to_credit_note/migration.sql b/prisma/migrations/20250106080031_add_value_to_credit_note/migration.sql new file mode 100644 index 0000000..6e94b07 --- /dev/null +++ b/prisma/migrations/20250106080031_add_value_to_credit_note/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CreditNote" ADD COLUMN "value" DOUBLE PRECISION NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20250107023936_add_code_field/migration.sql b/prisma/migrations/20250107023936_add_code_field/migration.sql new file mode 100644 index 0000000..e64b47f --- /dev/null +++ b/prisma/migrations/20250107023936_add_code_field/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `code` to the `CreditNote` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "CreditNote" ADD COLUMN "code" TEXT NOT NULL; diff --git a/prisma/migrations/20250107085603_add_metadata_to_credit_note/migration.sql b/prisma/migrations/20250107085603_add_metadata_to_credit_note/migration.sql new file mode 100644 index 0000000..a9936d3 --- /dev/null +++ b/prisma/migrations/20250107085603_add_metadata_to_credit_note/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "CreditNote" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "createdByUserId" TEXT; + +-- AddForeignKey +ALTER TABLE "CreditNote" ADD CONSTRAINT "CreditNote_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20250108042351_add_restart_status/migration.sql b/prisma/migrations/20250108042351_add_restart_status/migration.sql new file mode 100644 index 0000000..e47bb4e --- /dev/null +++ b/prisma/migrations/20250108042351_add_restart_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "TaskStatus" ADD VALUE 'Restart'; diff --git a/prisma/migrations/20250108042549_add_restart_for_user_task_status/migration.sql b/prisma/migrations/20250108042549_add_restart_for_user_task_status/migration.sql new file mode 100644 index 0000000..f19660e --- /dev/null +++ b/prisma/migrations/20250108042549_add_restart_for_user_task_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "UserTaskStatus" ADD VALUE 'Restart'; diff --git a/prisma/migrations/20250108075050_add_date_to_user_task/migration.sql b/prisma/migrations/20250108075050_add_date_to_user_task/migration.sql new file mode 100644 index 0000000..e7d0e94 --- /dev/null +++ b/prisma/migrations/20250108075050_add_date_to_user_task/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "UserTask" ADD COLUMN "acceptedAt" TIMESTAMP(3), +ADD COLUMN "submittedAt" TIMESTAMP(3); diff --git a/prisma/migrations/20250109063649_add_more_field_credit_note/migration.sql b/prisma/migrations/20250109063649_add_more_field_credit_note/migration.sql new file mode 100644 index 0000000..d56f956 --- /dev/null +++ b/prisma/migrations/20250109063649_add_more_field_credit_note/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "CreditNotePaybackType" AS ENUM ('Cash', 'BankTransfer'); + +-- AlterTable +ALTER TABLE "CreditNote" ADD COLUMN "detail" TEXT, +ADD COLUMN "paybackAccount" TEXT, +ADD COLUMN "paybackAccountName" TEXT, +ADD COLUMN "paybackBank" TEXT, +ADD COLUMN "paybackType" "CreditNotePaybackType", +ADD COLUMN "reason" TEXT; diff --git a/prisma/migrations/20250109100013_add_payback_status/migration.sql b/prisma/migrations/20250109100013_add_payback_status/migration.sql new file mode 100644 index 0000000..ba8a319 --- /dev/null +++ b/prisma/migrations/20250109100013_add_payback_status/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "PaybackStatus" AS ENUM ('Pending', 'Verify', 'Done'); + +-- AlterTable +ALTER TABLE "CreditNote" ADD COLUMN "paybackStatus" "PaybackStatus" NOT NULL DEFAULT 'Pending'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9341741..d69cca0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -482,6 +482,7 @@ model User { notificationReceive Notification[] @relation("NotificationReceiver") notificationRead Notification[] taskOrderCreated TaskOrder[] @relation("TaskOrderCreatedByUser") + creditNoteCreated CreditNote[] @relation("CreditNoteCreatedByUser") requestWorkStepStatus RequestWorkStepStatus[] userTask UserTask[] @@ -1497,6 +1498,7 @@ enum TaskStatus { Success Failed + Restart Redo Validate @@ -1559,6 +1561,7 @@ model TaskOrder { enum UserTaskStatus { Pending // Should not be use but define here for type + Restart Accept Submit } @@ -1572,16 +1575,53 @@ model UserTask { taskOrderId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String + + acceptedAt DateTime? + submittedAt DateTime? +} + +enum CreditNoteStatus { + Pending + Success +} + +enum CreditNotePaybackType { + Cash + BankTransfer +} + +enum PaybackStatus { + Pending + Verify + Done } model CreditNote { id String @id @default(cuid()) + code String + + creditNoteStatus CreditNoteStatus @default(Pending) + + value Float @default(0) + reason String? + detail String? + + paybackType CreditNotePaybackType? + paybackBank String? + paybackAccount String? + paybackAccountName String? + paybackStatus PaybackStatus @default(Pending) + quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade) quotationId String // NOTE: only status cancel requestWork RequestWork[] + + createdAt DateTime @default(now()) + createdBy User? @relation(name: "CreditNoteCreatedByUser", fields: [createdByUserId], references: [id]) + createdByUserId String? } model DebitNote { diff --git a/src/controllers/02-user-controller.ts b/src/controllers/02-user-controller.ts index 7c34b48..f5ddbde 100644 --- a/src/controllers/02-user-controller.ts +++ b/src/controllers/02-user-controller.ts @@ -642,11 +642,13 @@ export class UserController extends Controller { } } - if (body.username) { + if (body.username || body.email || body.firstName || body.lastName) { await editUser(userId, { + firstName: body.firstName, + lastName: body.lastName, username: body.username, email: body.email, - enabled: body.status !== "INACTIVE", + enabled: body.status ? body.status !== "INACTIVE" : undefined, }); } else if (body.status) { await editUser(userId, { enabled: body.status !== "INACTIVE" }); diff --git a/src/controllers/05-quotation-controller.ts b/src/controllers/05-quotation-controller.ts index bdaba92..a85d8aa 100644 --- a/src/controllers/05-quotation-controller.ts +++ b/src/controllers/05-quotation-controller.ts @@ -29,6 +29,7 @@ import { queryOrNot } from "../utils/relation"; import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio"; import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; +import { RequestWorkStatus } from "../generated/kysely/types"; type QuotationCreate = { registeredBranchId: string; @@ -190,6 +191,8 @@ export class QuotationController extends Controller { @Query() payCondition?: PayCondition, @Query() status?: QuotationStatus, @Query() urgentFirst?: boolean, + @Query() includeRegisteredBranch?: boolean, + @Query() hasCancel?: boolean, @Query() code?: string, @Query() query = "", ) { @@ -214,6 +217,18 @@ export class QuotationController extends Controller { payCondition, registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, quotationStatus: status, + requestData: hasCancel + ? { + some: { + requestWork: { + some: { + creditNoteId: null, + stepStatus: { some: { workStatus: RequestWorkStatus.Canceled } }, + }, + }, + }, + } + : undefined, } satisfies Prisma.QuotationWhereInput; const [result, total] = await prisma.$transaction([ @@ -223,6 +238,7 @@ export class QuotationController extends Controller { _count: { select: { worker: true }, }, + registeredBranch: includeRegisteredBranch, customerBranch: { include: { customer: { @@ -246,6 +262,46 @@ export class QuotationController extends Controller { prisma.quotation.count({ where }), ]); + if (hasCancel) { + const canceled = await prisma.requestData.findMany({ + include: { + _count: { + select: { + requestWork: { + where: { + creditNoteId: null, + stepStatus: { some: { workStatus: RequestWorkStatus.Canceled } }, + }, + }, + }, + }, + }, + where: { + requestWork: { + some: { + creditNoteId: null, + stepStatus: { some: { workStatus: RequestWorkStatus.Canceled } }, + }, + }, + quotationId: { in: result.map((v) => v.id) }, + }, + }); + + return { + result: result.map((v) => { + const canceledCount = canceled + .filter((item) => item.quotationId === v.id) + .reduce((a, c) => a + c._count.requestWork, 0); + return Object.assign(v, { + _count: { ...v._count, canceledWork: canceledCount }, + }); + }), + page, + pageSize, + total, + }; + } + return { result: result, page, pageSize, total }; } @@ -419,11 +475,11 @@ export class QuotationController extends Controller { const list = body.productServiceList.map((v, i) => { const p = product.find((p) => p.id === v.productId)!; const price = body.agentPrice ? p.agentPrice : p.price; - const pricePerUnit = p.vatIncluded ? precisionRound(price / (1 + VAT_DEFAULT)) : price; + const pricePerUnit = p.vatIncluded ? price / (1 + VAT_DEFAULT) : price; const vat = p.calcVat - ? precisionRound( - (pricePerUnit * (v.discount ? v.amount : 1) - (v.discount || 0)) * VAT_DEFAULT, - ) * (!v.discount ? v.amount : 1) + ? (pricePerUnit * (v.discount ? v.amount : 1) - (v.discount || 0)) * + VAT_DEFAULT * + (!v.discount ? v.amount : 1) : 0; return { @@ -684,11 +740,11 @@ export class QuotationController extends Controller { const list = body.productServiceList?.map((v, i) => { const p = product.find((p) => p.id === v.productId)!; const price = record.agentPrice ? p.agentPrice : p.price; - const pricePerUnit = p.vatIncluded ? precisionRound(price / (1 + VAT_DEFAULT)) : price; + const pricePerUnit = p.vatIncluded ? price / (1 + VAT_DEFAULT) : price; const vat = p.calcVat - ? precisionRound( - (pricePerUnit * (v.discount ? v.amount : 1) - (v.discount || 0)) * VAT_DEFAULT, - ) * (!v.discount ? v.amount : 1) + ? (pricePerUnit * (v.discount ? v.amount : 1) - (v.discount || 0)) * + VAT_DEFAULT * + (!v.discount ? v.amount : 1) : 0; return { order: i + 1, @@ -980,7 +1036,7 @@ export class QuotationActionController extends Controller { @Route("api/v1/quotation/{quotationId}/attachment") @Tags("Quotation") export class QuotationFileController extends Controller { - private async checkPermission(user: RequestWithUser["user"], id: string) { + async #checkPermission(user: RequestWithUser["user"], id: string) { const data = await prisma.quotation.findUnique({ include: { registeredBranch: { @@ -989,14 +1045,14 @@ export class QuotationFileController extends Controller { }, where: { id }, }); - if (!data) throw notFoundError("Payment"); + if (!data) throw notFoundError("Quotation"); await permissionCheck(user, data.registeredBranch); } @Get() @Security("keycloak") async listAttachment(@Request() req: RequestWithUser, @Path() quotationId: string) { - await this.checkPermission(req.user, quotationId); + await this.#checkPermission(req.user, quotationId); return await listFile(fileLocation.quotation.attachment(quotationId)); } @@ -1018,7 +1074,7 @@ export class QuotationFileController extends Controller { @Path() quotationId: string, @Path() name: string, ) { - await this.checkPermission(req.user, quotationId); + await this.#checkPermission(req.user, quotationId); return await getFile(fileLocation.quotation.attachment(quotationId, name)); } @@ -1029,7 +1085,7 @@ export class QuotationFileController extends Controller { @Path() quotationId: string, @Path() name: string, ) { - await this.checkPermission(req.user, quotationId); + await this.#checkPermission(req.user, quotationId); return await setFile(fileLocation.quotation.attachment(quotationId, name)); } @@ -1040,7 +1096,7 @@ export class QuotationFileController extends Controller { @Path() quotationId: string, @Path() name: string, ) { - await this.checkPermission(req.user, quotationId); + await this.#checkPermission(req.user, quotationId); return await deleteFile(fileLocation.quotation.attachment(quotationId, name)); } } diff --git a/src/controllers/06-request-list-controller.ts b/src/controllers/06-request-list-controller.ts index c967efd..f669f0a 100644 --- a/src/controllers/06-request-list-controller.ts +++ b/src/controllers/06-request-list-controller.ts @@ -292,6 +292,8 @@ export class RequestListController extends Controller { @Query() requestDataId?: string, @Query() workStatus?: RequestWorkStatus, @Query() readyToTask?: boolean, + @Query() cancelOnly?: boolean, + @Query() quotationId?: string, ) { let statusCondition: Prisma.RequestWorkWhereInput["stepStatus"] = {}; @@ -313,16 +315,29 @@ export class RequestListController extends Controller { ], }, }; - } else { + } + + if (cancelOnly) { + statusCondition = { + some: { workStatus: RequestWorkStatus.Canceled }, + }; + } + + if (workStatus && !readyToTask && !cancelOnly) { statusCondition = { some: { workStatus }, }; } const where = { - stepStatus: readyToTask || workStatus ? statusCondition : undefined, + stepStatus: readyToTask || cancelOnly || workStatus ? statusCondition : undefined, + creditNote: cancelOnly ? null : undefined, request: { id: requestDataId, + requestDataStatus: readyToTask + ? { notIn: [RequestDataStatus.Canceled, RequestDataStatus.Completed] } + : undefined, + quotationId, quotation: { registeredBranch: { OR: permissionCond(req.user) }, }, diff --git a/src/controllers/07-task-controller.ts b/src/controllers/07-task-controller.ts index dee4b7a..22357b4 100644 --- a/src/controllers/07-task-controller.ts +++ b/src/controllers/07-task-controller.ts @@ -125,6 +125,7 @@ export class TaskController extends Controller { userTask: true, taskList: true, institution: true, + registeredBranch: true, createdBy: true, }, }), @@ -150,6 +151,7 @@ export class TaskController extends Controller { where: { requestWorkStep: { responsibleUserId: taskAssignedUserId }, }, + orderBy: { id: "asc" }, include: { requestWorkStep: { include: { @@ -451,6 +453,7 @@ export class TaskActionController extends Controller { return await prisma.$transaction(async (tx) => { const promises = body.map(async (v) => { const record = await tx.task.findFirst({ + include: { requestWorkStep: true }, where: { step: v.step, requestWorkId: v.requestWorkId, @@ -458,6 +461,16 @@ export class TaskActionController extends Controller { }, }); if (!record) throw notFoundError("Task List"); + + if (v.taskStatus === TaskStatus.Restart && record.requestWorkStep.responsibleUserId) { + await tx.userTask.updateMany({ + where: { + taskOrderId: record.taskOrderId, + userId: record.requestWorkStep.responsibleUserId, + }, + data: { userTaskStatus: UserTaskStatus.Restart }, + }); + } return await tx.task.update({ where: { id: record.id }, data: { @@ -512,20 +525,14 @@ export class TaskActionController extends Controller { taskOrderId: taskOrderId, userId: submitUserId, }, - data: { userTaskStatus: UserTaskStatus.Submit }, + data: { userTaskStatus: UserTaskStatus.Submit, submittedAt: new Date() }, }), ]); } @Post("complete") @Security("keycloak") - async completeTaskOrder( - @Request() req: RequestWithUser, - @Path() taskOrderId: string, - @Query() submitUserId?: string, - ) { - submitUserId = submitUserId ?? req.user.sub; - + async completeTaskOrder(@Request() req: RequestWithUser, @Path() taskOrderId: string) { const record = await prisma.taskOrder.findFirst({ where: { id: taskOrderId } }); if (!record) throw notFoundError("Task Order"); @@ -534,21 +541,54 @@ export class TaskActionController extends Controller { await Promise.all([ tx.taskOrder.update({ where: { id: taskOrderId }, - data: { taskOrderStatus: TaskOrderStatus.Complete }, + data: { + taskOrderStatus: TaskOrderStatus.Complete, + userTask: { + updateMany: { + where: { taskOrderId }, + data: { + userTaskStatus: UserTaskStatus.Submit, + }, + }, + }, + }, }), tx.requestWorkStepStatus.updateMany({ where: { task: { - some: { taskOrderId, taskStatus: TaskStatus.Redo }, + some: { + taskOrderId, + taskStatus: { + notIn: [ + TaskStatus.Canceled, + TaskStatus.Success, + TaskStatus.Validate, + TaskStatus.Complete, + ], + }, + }, }, }, data: { workStatus: RequestWorkStatus.Ready }, }), + tx.task.updateMany({ + where: { + taskOrderId: taskOrderId, + taskStatus: { + notIn: [ + TaskStatus.Canceled, + TaskStatus.Success, + TaskStatus.Validate, + TaskStatus.Complete, + ], + }, + }, + data: { taskStatus: TaskStatus.Redo }, + }), tx.task.updateMany({ where: { taskOrderId: taskOrderId, taskStatus: TaskStatus.Validate, - requestWorkStep: { responsibleUserId: submitUserId }, }, data: { taskStatus: TaskStatus.Complete }, }), @@ -708,17 +748,38 @@ export class UserTaskController extends Controller { requestWorkStep: { responsibleUserId: req.user.sub }, }, }, - userTask: userTaskStatus - ? { - some: - userTaskStatus !== UserTaskStatus.Pending - ? { - userTaskStatus, - userId: req.user.sub, - } - : undefined, - none: userTaskStatus === UserTaskStatus.Pending ? { userId: req.user.sub } : undefined, - } + AND: userTaskStatus + ? [ + { + OR: + userTaskStatus === UserTaskStatus.Pending + ? [ + { + userTask: { + some: { + userTaskStatus: { + in: [UserTaskStatus.Pending, UserTaskStatus.Restart], + }, + userId: req.user.sub, + }, + }, + }, + { + userTask: { none: { userId: req.user.sub } }, + }, + ] + : undefined, + userTask: + userTaskStatus !== UserTaskStatus.Pending + ? { + some: { + userTaskStatus, + userId: req.user.sub, + }, + } + : undefined, + }, + ] : undefined, OR: queryOrNot(query, [ { code: { contains: query, mode: "insensitive" } }, @@ -735,6 +796,7 @@ export class UserTaskController extends Controller { userTask: { where: { userId: req.user.sub }, }, + registeredBranch: true, institution: true, createdBy: true, }, @@ -783,9 +845,11 @@ export class UserTaskController extends Controller { data: { taskOrderStatus: TaskOrderStatus.InProgress, userTask: { + deleteMany: { userId: req.user.sub }, create: { userId: req.user.sub, userTaskStatus: UserTaskStatus.Accept, + acceptedAt: new Date(), }, }, }, @@ -793,7 +857,7 @@ export class UserTaskController extends Controller { tx.task.updateMany({ where: { taskOrderId: taskOrderId, - taskStatus: TaskStatus.Pending, + taskStatus: { in: [TaskStatus.Pending, TaskStatus.Restart] }, requestWorkStep: { responsibleUserId: req.user.sub }, }, data: { @@ -812,13 +876,6 @@ export class UserTaskController extends Controller { }, data: { requestDataStatus: RequestDataStatus.InProgress }, }), - tx.userTask.create({ - data: { - userId: req.user.sub, - taskOrderId: taskOrderId, - userTaskStatus: UserTaskStatus.Accept, - }, - }), ]); await Promise.all(promises); diff --git a/src/controllers/08-credit-note-controller.ts b/src/controllers/08-credit-note-controller.ts index 31ec519..d47376c 100644 --- a/src/controllers/08-credit-note-controller.ts +++ b/src/controllers/08-credit-note-controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + Head, Path, Post, Put, @@ -13,7 +14,6 @@ import { Tags, } from "tsoa"; -// import { Prisma } from "@prisma/client"; import prisma from "../db"; import { RequestWithUser } from "../interfaces/user"; @@ -24,10 +24,11 @@ import { } from "../services/permission"; import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; +import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio"; import { notFoundError } from "../utils/error"; -import { Prisma } from "@prisma/client"; +import { CreditNotePaybackType, CreditNoteStatus, Prisma } from "@prisma/client"; import { queryOrNot } from "../utils/relation"; -import { RequestWorkStatus } from "../generated/kysely/types"; +import { PaybackStatus, RequestWorkStatus } from "../generated/kysely/types"; const MANAGE_ROLES = [ "system", @@ -53,10 +54,22 @@ const permissionCheckCompany = createPermCheck((_) => true); type CreditNoteCreate = { requestWorkId: string[]; quotationId: string; + reason?: string; + detail?: string; + paybackType?: CreditNotePaybackType; + paybackBank?: string; + paybackAccount?: string; + paybackAccountName?: string; }; type CreditNoteUpdate = { requestWorkId: string[]; quotationId: string; + reason?: string; + detail?: string; + paybackType?: CreditNotePaybackType; + paybackBank?: string; + paybackAccount?: string; + paybackAccountName?: string; }; @Route("api/v1/credit-note") @@ -64,7 +77,7 @@ type CreditNoteUpdate = { export class CreditNoteController extends Controller { @Get("stats") @Security("keycloak") - async getCreditNoteStats(@Request() req: RequestWithUser, @Query() quotationId: string) { + async getCreditNoteStats(@Request() req: RequestWithUser, @Query() quotationId?: string) { const where = { requestWork: { some: { @@ -88,8 +101,16 @@ export class CreditNoteController extends Controller { @Query() pageSize: number = 30, @Query() query: string = "", @Query() quotationId?: string, + @Query() creditNoteStatus?: CreditNoteStatus, ) { - return await this.getCreditNoteListByCriteria(req, page, pageSize, query, quotationId); + return await this.getCreditNoteListByCriteria( + req, + page, + pageSize, + query, + quotationId, + creditNoteStatus, + ); } // NOTE: only when needed or else remove this and implement in getCreditNoteList @@ -101,6 +122,7 @@ export class CreditNoteController extends Controller { @Query() pageSize: number = 30, @Query() query: string = "", @Query() quotationId?: string, + @Query() creditNoteStatus?: CreditNoteStatus, @Body() body?: {}, ) { const where = { @@ -146,6 +168,7 @@ export class CreditNoteController extends Controller { }, }, ]), + creditNoteStatus, requestWork: { some: { request: { @@ -162,6 +185,16 @@ export class CreditNoteController extends Controller { prisma.creditNote.findMany({ where, include: { + quotation: { + include: { + registeredBranch: true, + customerBranch: { + include: { + customer: true, + }, + }, + }, + }, requestWork: { include: { request: true }, }, @@ -191,8 +224,33 @@ export class CreditNoteController extends Controller { return prisma.creditNote.findFirst({ where, include: { + quotation: { + include: { + registeredBranch: true, + customerBranch: { + include: { + customer: true, + }, + }, + }, + }, requestWork: { - include: { request: true }, + include: { + request: { + include: { employee: true }, + }, + productService: { + include: { + service: true, + work: { + include: { productOnWork: true }, + }, + product: { + include: { document: true }, + }, + }, + }, + }, }, }, }); @@ -203,6 +261,7 @@ export class CreditNoteController extends Controller { async createCreditNote(@Request() req: RequestWithUser, @Body() body: CreditNoteCreate) { const requestWork = await prisma.requestWork.findMany({ where: { + creditNote: null, request: { quotation: { id: body.quotationId, @@ -216,6 +275,15 @@ export class CreditNoteController extends Controller { id: { in: body.requestWorkId }, }, include: { + stepStatus: true, + productService: { + include: { + product: true, + work: { + include: { productOnWork: true }, + }, + }, + }, request: { include: { quotation: { @@ -236,28 +304,74 @@ export class CreditNoteController extends Controller { 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, - }, - }); + const value = requestWork.reduce((a, c) => { + const serviceChargeStepCount = c.productService.work?.productOnWork.find( + (v) => v.productId === c.productService.productId, + )?.stepCount; + + const successCount = c.stepStatus.filter( + (v) => v.workStatus === RequestWorkStatus.Completed, + ).length; + + const price = c.request.quotation.agentPrice ? "price" : "agentPrice"; + + if (serviceChargeStepCount && successCount) { + return ( + a + + c.productService.product[price] - + c.productService.product.serviceCharge * successCount + ); + } + return a + c.productService.product.price; + }, 0); this.setStatus(HttpStatus.CREATED); - return record; + return await prisma.$transaction( + async (tx) => { + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + + const last = await tx.runningNo.upsert({ + where: { + key: `CREDIT_NOTE_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}`, + }, + create: { + key: `CREDIT_NOTE_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}`, + value: 1, + }, + update: { value: { increment: 1 } }, + }); + + return await prisma.creditNote.create({ + include: { + requestWork: { + include: { + request: true, + }, + }, + quotation: true, + }, + data: { + reason: body.reason, + detail: body.detail, + paybackType: body.paybackType, + paybackBank: body.paybackBank, + paybackAccount: body.paybackAccount, + paybackAccountName: body.paybackAccountName, + code: `CN${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${last.value.toString().padStart(6, "0")}`, + value, + requestWork: { + connect: body.requestWorkId.map((v) => ({ + id: v, + })), + }, + quotationId: body.quotationId, + }, + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); } @Put("{creditNoteId}") @@ -284,6 +398,7 @@ export class CreditNoteController extends Controller { const requestWork = await prisma.requestWork.findMany({ where: { + OR: [{ creditNote: null }, { creditNoteId }], request: { quotation: { id: body.quotationId, @@ -296,16 +411,55 @@ export class CreditNoteController extends Controller { }, id: { in: body.requestWorkId }, }, + include: { + stepStatus: true, + productService: { + include: { + product: true, + work: { + include: { productOnWork: true }, + }, + }, + }, + request: { + include: { + quotation: { + include: { + registeredBranch: { include: branchRelationPermInclude(req.user) }, + }, + }, + }, + }, + }, }); if (requestWork.length !== body.requestWorkId.length) { throw new HttpError(HttpStatus.BAD_REQUEST, "Not Match", "reqNotMet"); } + const value = requestWork.reduce((a, c) => { + const serviceChargeStepCount = c.productService.work?.productOnWork.find( + (v) => v.productId === c.productService.productId, + )?.stepCount; + + const successCount = c.stepStatus.filter( + (v) => v.workStatus === RequestWorkStatus.Completed, + ).length; + + const price = c.request.quotation.agentPrice ? "price" : "agentPrice"; + + if (serviceChargeStepCount && successCount) { + return ( + a + + c.productService.product[price] - + c.productService.product.serviceCharge * successCount + ); + } + return a + c.productService.product.price; + }, 0); + const record = await prisma.creditNote.update({ - where: { - id: creditNoteId, - }, + where: { id: creditNoteId }, include: { requestWork: { include: { @@ -315,6 +469,13 @@ export class CreditNoteController extends Controller { quotation: true, }, data: { + reason: body.reason, + detail: body.detail, + paybackType: body.paybackType, + paybackBank: body.paybackBank, + paybackAccount: body.paybackAccount, + paybackAccountName: body.paybackAccountName, + value, requestWork: { disconnect: creditNoteData.requestWork .map((item) => ({ @@ -353,3 +514,110 @@ export class CreditNoteController extends Controller { return await prisma.creditNote.delete({ where: { id: creditNoteId } }); } } + +@Route("api/v1/credit-note/{creditNoteId}") +@Tags("Credit Note") +export class CreditNoteActionController extends Controller { + async #checkPermission(user: RequestWithUser["user"], id: string) { + const creditNoteData = await prisma.creditNote.findFirst({ + where: { id }, + include: { + requestWork: true, + quotation: { + include: { + registeredBranch: { include: branchRelationPermInclude(user) }, + }, + }, + }, + }); + if (!creditNoteData) throw notFoundError("Credit Note"); + await permissionCheck(user, creditNoteData.quotation.registeredBranch); + return creditNoteData; + } + + @Post("payback-status") + @Security("keycloak", MANAGE_ROLES) + async updateStatus( + @Request() req: RequestWithUser, + @Path() creditNoteId: string, + @Body() body: PaybackStatus, + ) { + await this.#checkPermission(req.user, creditNoteId); + return await prisma.creditNote.update({ + where: { id: creditNoteId }, + include: { + requestWork: { + include: { + request: true, + }, + }, + quotation: true, + }, + data: { + creditNoteStatus: body === PaybackStatus.Done ? CreditNoteStatus.Success : undefined, + paybackStatus: body, + }, + }); + } +} + +@Route("api/v1/credit-note/{creditNoteId}") +@Tags("Credit Note") +export class CreditNoteAttachmentController extends Controller { + async #checkPermission(user: RequestWithUser["user"], id: string) { + const creditNoteData = await prisma.creditNote.findFirst({ + where: { id }, + include: { + requestWork: true, + quotation: { + include: { + registeredBranch: { include: branchRelationPermInclude(user) }, + }, + }, + }, + }); + if (!creditNoteData) throw notFoundError("Credit Note"); + await permissionCheck(user, creditNoteData.quotation.registeredBranch); + return creditNoteData; + } + + @Get("attachment") + @Security("keycloak") + async listAttachment(@Request() req: RequestWithUser, @Path() creditNoteId: string) { + await this.#checkPermission(req.user, creditNoteId); + return await listFile(fileLocation.creditNote.attachment(creditNoteId)); + } + + @Get("attachment/{name}") + @Security("keycloak") + async getAttachment(@Path() creditNoteId: string, @Path() name: string) { + return await getFile(fileLocation.creditNote.attachment(creditNoteId, name)); + } + + @Head("attachment/{name}") + async headAttachment(@Path() creditNoteId: string, @Path() name: string) { + return await getPresigned("head", fileLocation.creditNote.attachment(creditNoteId, name)); + } + + @Put("attachment/{name}") + @Security("keycloak") + async putAttachment( + @Request() req: RequestWithUser, + @Path() creditNoteId: string, + @Path() name: string, + ) { + await this.#checkPermission(req.user, creditNoteId); + return await setFile(fileLocation.creditNote.attachment(creditNoteId, name)); + } + + @Delete("attachment/{name}") + @Security("keycloak") + async delAttachment( + @Request() req: RequestWithUser, + @Path() creditNoteId: string, + @Path() name: string, + ) { + await this.#checkPermission(req.user, creditNoteId); + return await deleteFile(fileLocation.creditNote.attachment(creditNoteId, name)); + } +} diff --git a/src/utils/minio.ts b/src/utils/minio.ts index 4a2cfef..7ec0a3b 100644 --- a/src/utils/minio.ts +++ b/src/utils/minio.ts @@ -112,4 +112,7 @@ export const fileLocation = { task: { attachment: (taskId: string, name?: string) => `task/attachment-${taskId}/${name || ""}`, }, + creditNote: { + attachment: (taskId: string, name?: string) => `credit-note/attachment-${taskId}/${name || ""}`, + }, };