import { Body, Controller, Delete, Get, Head, Path, Post, Put, Query, Request, Route, Security, Tags, } from "tsoa"; import config from "../config.json"; 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 { deleteFile, deleteFolder, fileLocation, getFile, getPresigned, listFile, setFile, } from "../utils/minio"; import { notFoundError } from "../utils/error"; import { CreditNotePaybackType, CreditNoteStatus, Prisma, RequestDataStatus } from "@prisma/client"; import { queryOrNot, whereDateQuery } from "../utils/relation"; import { PaybackStatus, RequestWorkStatus } from "../generated/kysely/types"; const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "executive", "accountant", "branch_admin", "branch_manager", "branch_accountant", ]; function globalAllow(user: RequestWithUser["user"]) { const listAllowed = ["system", "head_of_admin", "admin", "executive", "accountant"]; return user.roles?.some((v) => listAllowed.includes(v)) || false; } const VAT_DEFAULT = config.vat; const permissionCond = createPermCondition(globalAllow); const permissionCheck = createPermCheck(globalAllow); type CreditNoteCreate = { requestWorkId: string[]; quotationId: string; reason?: string; detail?: string; remark?: string; paybackType?: CreditNotePaybackType; paybackBank?: string; paybackAccount?: string; paybackAccountName?: string; }; type CreditNoteUpdate = { requestWorkId: string[]; quotationId: string; reason?: string; detail?: string; remark?: string; paybackType?: CreditNotePaybackType; paybackBank?: string; paybackAccount?: string; paybackAccountName?: string; }; @Route("api/v1/credit-note") @Tags("Credit Note") export class CreditNoteController extends Controller { async #getLineToken() { if (!process.env.LINE_MESSAGING_API_TOKEN) { console.warn("Line Webhook Activated but LINE_MESSAGING_API_TOKEN not set."); } return process.env.LINE_MESSAGING_API_TOKEN; } @Get("stats") @Security("keycloak") async getCreditNoteStats(@Request() req: RequestWithUser, @Query() quotationId?: string) { const where = { requestWork: { some: { request: { quotationId, quotation: { registeredBranch: { OR: permissionCond(req.user) }, }, }, }, }, } satisfies Prisma.CreditNoteWhereInput; const result = await prisma.creditNote.groupBy({ _count: true, by: "creditNoteStatus", where, }); return result.reduce>((a, c) => { a[c.creditNoteStatus] = c._count; return a; }, {}); } @Get() @Security("keycloak") async getCreditNoteList( @Request() req: RequestWithUser, @Query() page: number = 1, @Query() pageSize: number = 30, @Query() query: string = "", @Query() quotationId?: string, @Query() creditNoteStatus?: CreditNoteStatus, @Query() startDate?: Date, @Query() endDate?: Date, ) { return await this.getCreditNoteListByCriteria( req, page, pageSize, query, quotationId, creditNoteStatus, startDate, endDate, ); } // 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, @Query() creditNoteStatus?: CreditNoteStatus, @Query() startDate?: Date, @Query() endDate?: Date, ) { const where = { OR: queryOrNot(query, [ { code: { contains: query, mode: "insensitive" } }, { requestWork: { some: { request: { OR: queryOrNot(query, [ { quotation: { code: { contains: query, mode: "insensitive" } } }, { quotation: { workName: { contains: query, mode: "insensitive" } } }, { quotation: { customerBranch: { OR: [ { code: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } }, { firstNameEN: { contains: query, mode: "insensitive" } }, { lastName: { contains: query, mode: "insensitive" } }, { lastNameEN: { contains: query, mode: "insensitive" } }, ], }, }, employee: { OR: [ { employeePassport: { some: { number: { contains: query, mode: "insensitive" } }, }, }, { code: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } }, { firstNameEN: { contains: query, mode: "insensitive" } }, { lastName: { contains: query, mode: "insensitive" } }, { lastNameEN: { contains: query, mode: "insensitive" } }, ], }, }, ]), }, }, }, }, ]), creditNoteStatus, requestWork: { some: { request: { quotationId, quotation: { registeredBranch: { OR: permissionCond(req.user) }, }, }, }, }, ...whereDateQuery(startDate, endDate), } satisfies Prisma.CreditNoteWhereInput; const [result, total] = await prisma.$transaction([ prisma.creditNote.findMany({ where, take: pageSize, skip: (page - 1) * pageSize, include: { quotation: { include: { registeredBranch: true, customerBranch: { include: { customer: true, }, }, }, }, requestWork: { include: { request: true }, }, }, orderBy: [{ createdAt: "desc" }], }), 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: permissionCond(req.user) }, }, }, }, }, } satisfies Prisma.CreditNoteWhereInput; return prisma.creditNote.findFirst({ where, include: { quotation: { include: { registeredBranch: true, customerBranch: { include: { customer: true, }, }, }, }, requestWork: { include: { request: { include: { employee: true }, }, productService: { include: { service: true, work: { include: { productOnWork: true }, }, product: { include: { document: true }, }, }, }, }, }, }, }); } @Post() @Security("keycloak", MANAGE_ROLES) async createCreditNote(@Request() req: RequestWithUser, @Body() body: CreditNoteCreate) { const requestWork = await prisma.requestWork.findMany({ where: { creditNote: null, request: { quotation: { id: body.quotationId, }, }, OR: [ { request: { requestDataStatus: RequestDataStatus.Canceled } }, { stepStatus: { some: { workStatus: RequestWorkStatus.Canceled } } }, ], 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"); } await Promise.all( requestWork.map((item) => permissionCheck(req.user, item.request.quotation.registeredBranch)), ); 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.productService.pricePerUnit * (1 + (c.productService.vat > 0 ? VAT_DEFAULT : 0)) - c.productService.discount; if (serviceChargeStepCount && successCount) { return a + price - c.productService.product.serviceCharge * successCount; } return a + price; }, 0); this.setStatus(HttpStatus.CREATED); 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: { include: { customerBranch: { include: { customer: { include: { branch: { where: { userId: { not: null } } } } }, }, }, }, }, }, data: { reason: body.reason, detail: body.detail, remark: body.remark, 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, }, }) .then(async (res) => { const token = await this.#getLineToken(); if (!token) return; const textHead = "JWS ALERT:"; const textAlert = "ขอแจ้งให้ทราบว่าใบลดหนี้"; const textAlert2 = "ได้ถูกสร้างขึ้นเรียบร้อยแล้ว"; const textAlert3 = "หากท่านต้องการข้อมูลเพิ่มเติมหรือมีข้อสงสัยประการใด โปรดแจ้งให้ฝ่ายที่เกี่ยวข้องทราบ ทางเรายินดีให้ความช่วยเหลืออย่างเต็มที่ 🙏"; let finalTextWork = ""; let textData = ""; let dataCustomerId: string[] = []; let dataUserId: string[] = []; if (res) { res.quotation.customerBranch.customer.branch.forEach((item) => { if (!dataCustomerId?.includes(item.id) && item.userId) { dataCustomerId.push(item.id); dataUserId.push(item.userId); } }); finalTextWork = `จำนวนเงิน ${res.value.toFixed(2)} บาท `; } textData = `${textHead}\n\n${textAlert}\n${finalTextWork}${textAlert2}\n\n${textAlert3}`; const data = { to: dataUserId, messages: [ { type: "text", text: textData, }, ], }; await fetch("https://api.line.me/v2/bot/message/multicast", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(data), }); return res; }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); } @Put("{creditNoteId}") @Security("keycloak") 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 }, }, AND: [ { OR: [{ creditNote: null }, { creditNoteId }], }, { OR: [ { request: { requestDataStatus: RequestDataStatus.Canceled } }, { stepStatus: { some: { workStatus: RequestWorkStatus.Canceled } } }, ], }, ], 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.productService.pricePerUnit * (1 + (c.productService.vat > 0 ? VAT_DEFAULT : 0)) - c.productService.discount; if (serviceChargeStepCount && successCount) { return a + price - c.productService.product.serviceCharge * successCount; } return a + price; }, 0); const record = await prisma.creditNote.update({ where: { id: creditNoteId }, include: { requestWork: { include: { request: true, }, }, quotation: true, }, data: { reason: body.reason, detail: body.detail, remark: body.remark, paybackType: body.paybackType, paybackBank: body.paybackBank, paybackAccount: body.paybackAccount, paybackAccountName: body.paybackAccountName, value, 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); if (record.creditNoteStatus !== CreditNoteStatus.Waiting) { throw new HttpError( HttpStatus.BAD_REQUEST, "Accepted credit note cannot be deleted", "creditNoteAcceptedNoDelete", ); } await Promise.all([ deleteFolder(fileLocation.creditNote.slip(creditNoteId)), deleteFolder(fileLocation.creditNote.attachment(creditNoteId)), ]); 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; } async #getLineToken() { if (!process.env.LINE_MESSAGING_API_TOKEN) { console.warn("Line Webhook Activated but LINE_MESSAGING_API_TOKEN not set."); } return process.env.LINE_MESSAGING_API_TOKEN; } @Post("accept") @Security("keycloak", MANAGE_ROLES) async acceptCreditNote(@Request() req: RequestWithUser, @Path() creditNoteId: string) { await this.#checkPermission(req.user, creditNoteId); return await prisma.creditNote.update({ where: { id: creditNoteId }, data: { creditNoteStatus: CreditNoteStatus.Pending }, }); } @Post("payback-status") @Security("keycloak", MANAGE_ROLES) async updateStatus( @Request() req: RequestWithUser, @Path() creditNoteId: string, @Body() body: { paybackStatus: PaybackStatus }, ) { await this.#checkPermission(req.user, creditNoteId); return await prisma.creditNote .update({ where: { id: creditNoteId }, include: { requestWork: { include: { request: true, }, }, quotation: { include: { customerBranch: { include: { customer: { include: { branch: { where: { userId: { not: null } } } } }, }, }, }, }, }, data: { creditNoteStatus: body.paybackStatus === PaybackStatus.Done ? CreditNoteStatus.Success : undefined, paybackStatus: body.paybackStatus, paybackDate: body.paybackStatus === PaybackStatus.Done ? new Date() : undefined, }, }) .then(async (res) => { const token = await this.#getLineToken(); if (!token) return; const textHead = "JWS ALERT:"; const textAlert = "ทางเราขอแจ้งให้ทราบว่าการดำเนินการคืนเงินสำหรับใบลดหนี้"; const textAlert2 = "ได้รับการอนุมัติและเสร็จสมบูรณ์เรียบร้อยแล้ว"; const textAlert3 = "หากท่านต้องการข้อมูลเพิ่มเติมหรือมีข้อสงสัยประการใด โปรดแจ้งให้ฝ่ายที่เกี่ยวข้องทราบ ทางเรายินดีให้ความช่วยเหลืออย่างเต็มที่ 🙏"; let finalTextWork = ""; let textData = ""; let dataCustomerId: string[] = []; let textWorkList: string[] = []; let dataUserId: string[] = []; if (res) { res.quotation.customerBranch.customer.branch.forEach((item) => { if (!dataCustomerId?.includes(item.id) && item.userId) { dataCustomerId.push(item.id); dataUserId.push(item.userId); } }); finalTextWork = `จำนวนเงิน ${res.value.toFixed(2)} บาท `; } textData = `${textHead}\n\n${textAlert}\n${finalTextWork}${textAlert2}\n\n${textAlert3}`; const data = { to: dataUserId, messages: [ { type: "text", text: textData, }, ], }; body.paybackStatus === PaybackStatus.Done ? await fetch("https://api.line.me/v2/bot/message/multicast", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(data), }) : undefined; }); } } @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)); } @Get("file-slip") @Security("keycloak") async listSlip(@Request() req: RequestWithUser, @Path() creditNoteId: string) { await this.#checkPermission(req.user, creditNoteId); return await listFile(fileLocation.creditNote.slip(creditNoteId)); } @Get("file-slip/{name}") @Security("keycloak") async getSlip(@Path() creditNoteId: string, @Path() name: string) { return await getFile(fileLocation.creditNote.slip(creditNoteId, name)); } @Head("file-slip/{name}") async headSlip(@Path() creditNoteId: string, @Path() name: string) { return await getPresigned("head", fileLocation.creditNote.slip(creditNoteId, name)); } @Put("file-slip/{name}") @Security("keycloak") async putSlip( @Request() req: RequestWithUser, @Path() creditNoteId: string, @Path() name: string, ) { await this.#checkPermission(req.user, creditNoteId); return await setFile(fileLocation.creditNote.slip(creditNoteId, name)); } @Delete("file-slip/{name}") @Security("keycloak") async delSlip( @Request() req: RequestWithUser, @Path() creditNoteId: string, @Path() name: string, ) { await this.#checkPermission(req.user, creditNoteId); return await deleteFile(fileLocation.creditNote.slip(creditNoteId, name)); } }