diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml index 1aebe39..86b83f0 100644 --- a/.forgejo/workflows/deploy.yaml +++ b/.forgejo/workflows/deploy.yaml @@ -33,22 +33,14 @@ jobs: platforms: linux/amd64 tags: ${{ env.CONTAINER_IMAGE_NAME }} push: true - - name: Remote Deploy Development + - name: Remote Deploy uses: appleboy/ssh-action@v1.2.1 with: - host: ${{ vars.SSH_DEVELOPMENT_HOST }} - port: ${{ vars.SSH_DEVELOPMENT_PORT }} - username: ${{ secrets.SSH_DEVELOPMENT_USER }} - password: ${{ secrets.SSH_DEVELOPMENT_PASSWORD }} - script: eval "${{ secrets.SSH_DEVELOPMENT_DEPLOY_CMD }}" - - name: Remote Deploy Test - uses: appleboy/ssh-action@v1.2.1 - with: - host: ${{ vars.SSH_TEST_HOST }} - port: ${{ vars.SSH_TEST_PORT }} - username: ${{ secrets.SSH_TEST_USER }} - password: ${{ secrets.SSH_TEST_PASSWORD }} - script: eval "${{ secrets.SSH_TEST_DEPLOY_CMD }}" + host: ${{ vars.SSH_DEPLOY_HOST }} + port: ${{ vars.SSH_DEPLOY_PORT }} + username: ${{ secrets.SSH_DEPLOY_USER }} + password: ${{ secrets.SSH_DEPLOY_PASSWORD }} + script: eval "${{ secrets.SSH_DEPLOY_CMD }}" - name: Notify Discord Success if: success() run: | diff --git a/.forgejo/workflows/spellcheck.yaml b/.forgejo/workflows/spellcheck.yaml index 468d970..b6a397f 100644 --- a/.forgejo/workflows/spellcheck.yaml +++ b/.forgejo/workflows/spellcheck.yaml @@ -3,8 +3,7 @@ name: Spell Check permissions: contents: read -on: - push: +on: [push, pull_request] env: CLICOLOR: 1 diff --git a/package.json b/package.json index ced7595..eafc24f 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "fast-jwt": "^5.0.5", + "json-2-csv": "^5.5.8", "kysely": "^0.27.5", "minio": "^8.0.2", "morgan": "^1.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7495ebe..85397d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: fast-jwt: specifier: ^5.0.5 version: 5.0.5 + json-2-csv: + specifier: ^5.5.8 + version: 5.5.8 kysely: specifier: ^0.27.5 version: 0.27.5 @@ -904,6 +907,10 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} + deeks@3.1.0: + resolution: {integrity: sha512-e7oWH1LzIdv/prMQ7pmlDlaVoL64glqzvNgkgQNgyec9ORPHrT2jaOqMtRyqJuwWjtfb6v+2rk9pmaHj+F137A==} + engines: {node: '>= 16'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -932,6 +939,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + doc-path@4.1.1: + resolution: {integrity: sha512-h1ErTglQAVv2gCnOpD3sFS6uolDbOKHDU1BZq+Kl3npPqroU3dYL42lUgMfd5UimlwtRgp7C9dLGwqQ5D2HYgQ==} + engines: {node: '>=16'} + docx-templates@4.13.0: resolution: {integrity: sha512-tTmR3WhROYctuyVReQ+PfCU3zprmC45/VuSVzn8EjovzpRkXYUdXiDatB9M8pasj0V+wuuOyY8bcSHvlQ2GNag==} engines: {node: '>=6'} @@ -1535,6 +1546,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + json-2-csv@5.5.8: + resolution: {integrity: sha512-eMQHOwV+av8Sgo+fkbEbQWOw/kwh89AZ5fNA8TYfcooG6TG1ZOL2WcPUrngIMIK8dBJitQ8QEU0zbncQ0CX4CQ==} + engines: {node: '>= 16'} + json-bignum@0.0.3: resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} engines: {node: '>=0.8'} @@ -3778,6 +3793,8 @@ snapshots: decode-uri-component@0.2.2: {} + deeks@3.1.0: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -3811,6 +3828,8 @@ snapshots: dependencies: path-type: 4.0.0 + doc-path@4.1.1: {} + docx-templates@4.13.0: dependencies: jszip: 3.10.1 @@ -4568,6 +4587,11 @@ snapshots: js-tokens@4.0.0: {} + json-2-csv@5.5.8: + dependencies: + deeks: 3.1.0 + doc-path: 4.1.1 + json-bignum@0.0.3: {} json-parse-even-better-errors@2.3.1: {} diff --git a/prisma/migrations/20250305041645_add_noti_perm_related_field/migration.sql b/prisma/migrations/20250305041645_add_noti_perm_related_field/migration.sql new file mode 100644 index 0000000..95388f6 --- /dev/null +++ b/prisma/migrations/20250305041645_add_noti_perm_related_field/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Notification" ADD COLUMN "registeredBranchId" TEXT; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_registeredBranchId_fkey" FOREIGN KEY ("registeredBranchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20250305070931_update_field/migration.sql b/prisma/migrations/20250305070931_update_field/migration.sql new file mode 100644 index 0000000..7d3ee1a --- /dev/null +++ b/prisma/migrations/20250305070931_update_field/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "RequestData" ADD COLUMN "customerRequestCancel" BOOLEAN, +ADD COLUMN "customerRequestCancelReason" TEXT; + +-- AlterTable +ALTER TABLE "RequestWork" ADD COLUMN "customerRequestCancel" BOOLEAN, +ADD COLUMN "customerRequestCancelReason" TEXT; diff --git a/prisma/migrations/20250305101414_add_mark_delete_noti/migration.sql b/prisma/migrations/20250305101414_add_mark_delete_noti/migration.sql new file mode 100644 index 0000000..5be0ce5 --- /dev/null +++ b/prisma/migrations/20250305101414_add_mark_delete_noti/migration.sql @@ -0,0 +1,48 @@ +/* + Warnings: + + - You are about to drop the `_NotificationToUser` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_NotificationToUser" DROP CONSTRAINT "_NotificationToUser_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_NotificationToUser" DROP CONSTRAINT "_NotificationToUser_B_fkey"; + +-- DropTable +DROP TABLE "_NotificationToUser"; + +-- CreateTable +CREATE TABLE "_NotificationRead" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_NotificationRead_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "_NotificationDelete" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_NotificationDelete_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_NotificationRead_B_index" ON "_NotificationRead"("B"); + +-- CreateIndex +CREATE INDEX "_NotificationDelete_B_index" ON "_NotificationDelete"("B"); + +-- AddForeignKey +ALTER TABLE "_NotificationRead" ADD CONSTRAINT "_NotificationRead_A_fkey" FOREIGN KEY ("A") REFERENCES "Notification"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_NotificationRead" ADD CONSTRAINT "_NotificationRead_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_NotificationDelete" ADD CONSTRAINT "_NotificationDelete_A_fkey" FOREIGN KEY ("A") REFERENCES "Notification"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_NotificationDelete" ADD CONSTRAINT "_NotificationDelete_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5cfd276..71d3b60 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,12 +21,16 @@ model Notification { groupReceiver NotificationGroup[] + registeredBranchId String? + registeredBranch Branch? @relation(fields: [registeredBranchId], references: [id]) + receiver User? @relation(name: "NotificationReceiver", fields: [receiverId], references: [id], onDelete: Cascade) receiverId String? createdAt DateTime @default(now()) - readByUser User[] + readByUser User[] @relation(name: "NotificationRead") + deleteByUser User[] @relation(name: "NotificationDelete") } model NotificationGroup { @@ -313,6 +317,7 @@ model Branch { quotation Quotation[] workflowTemplate WorkflowTemplate[] taskOrder TaskOrder[] + notification Notification[] } model BranchBank { @@ -480,7 +485,8 @@ model User { invoiceCreated Invoice[] paymentCreated Payment[] notificationReceive Notification[] @relation("NotificationReceiver") - notificationRead Notification[] + notificationRead Notification[] @relation("NotificationRead") + notificationDelete Notification[] @relation("NotificationDelete") taskOrderCreated TaskOrder[] @relation("TaskOrderCreatedByUser") creditNoteCreated CreditNote[] @relation("CreditNoteCreatedByUser") @@ -1438,6 +1444,9 @@ model RequestData { requestDataStatus RequestDataStatus @default(Pending) + customerRequestCancel Boolean? + customerRequestCancelReason String? + flow Json? requestWork RequestWork[] @@ -1473,6 +1482,9 @@ model RequestWork { stepStatus RequestWorkStepStatus[] + customerRequestCancel Boolean? + customerRequestCancelReason String? + creditNote CreditNote? @relation(fields: [creditNoteId], references: [id], onDelete: SetNull) creditNoteId String? } diff --git a/src/controllers/00-doc-template-controller.ts b/src/controllers/00-doc-template-controller.ts index e375c55..c0e6fff 100644 --- a/src/controllers/00-doc-template-controller.ts +++ b/src/controllers/00-doc-template-controller.ts @@ -2,7 +2,7 @@ import createReport from "docx-templates"; import ThaiBahtText from "thai-baht-text"; import { District, Province, SubDistrict } from "@prisma/client"; import { Readable } from "node:stream"; -import { Controller, Get, Path, Query, Route } from "tsoa"; +import { Controller, Get, Path, Query, Route, Tags } from "tsoa"; import prisma from "../db"; import { notFoundError } from "../utils/error"; import HttpError from "../interfaces/http-error"; @@ -62,6 +62,7 @@ const quotationData = (id: string) => }); @Route("api/v1/doc-template") +@Tags("Document Template") export class DocTemplateController extends Controller { @Get() async getTemplate() { diff --git a/src/controllers/00-notification-controller.ts b/src/controllers/00-notification-controller.ts index 1718e04..2bc4bf0 100644 --- a/src/controllers/00-notification-controller.ts +++ b/src/controllers/00-notification-controller.ts @@ -5,7 +5,6 @@ import { Get, Path, Post, - Put, Query, Request, Route, @@ -13,10 +12,14 @@ import { Tags, } from "tsoa"; import { RequestWithUser } from "../interfaces/user"; -import HttpStatus from "../interfaces/http-status"; +import prisma from "../db"; +import { Prisma } from "@prisma/client"; +import { queryOrNot } from "../utils/relation"; +import { notFoundError } from "../utils/error"; +import dayjs from "dayjs"; +import { createPermCondition } from "../services/permission"; -type NotificationCreate = {}; -type NotificationUpdate = {}; +const permissionCondCompany = createPermCondition((_) => true); @Route("/api/v1/notification") @Tags("Notification") @@ -29,12 +32,53 @@ export class NotificationController extends Controller { @Query() pageSize: number = 30, @Query() query = "", ) { - const total = 0; - - // TODO: implement + const where: Prisma.NotificationWhereInput = { + AND: [ + { + OR: queryOrNot<(typeof where)[]>(query, [ + { title: { contains: query } }, + { detail: { contains: query } }, + ]), + }, + { + OR: [ + { receiverId: req.user.sub }, + req.user.roles.length > 0 + ? { + groupReceiver: { some: { name: { in: req.user.roles } } }, + registeredBranch: { OR: permissionCondCompany(req.user) }, + } + : {}, + ], + }, + ], + NOT: { + OR: [ + { + readByUser: { some: { id: req.user.sub } }, + createdAt: { lte: dayjs().subtract(7, "days").toDate() }, + }, + { deleteByUser: { some: { id: req.user.sub } } }, + ], + }, + }; + const [result, total] = await prisma.$transaction([ + prisma.notification.findMany({ + where, + include: { readByUser: true }, + orderBy: { createdAt: "desc" }, + }), + prisma.notification.count({ where }), + ]); return { - result: [], + result: result.map((v) => ({ + id: v.id, + title: v.title, + detail: v.detail, + createdAt: v.createdAt, + read: v.readByUser.some((v) => v.id === req.user.sub), + })), page, pageSize, total, @@ -44,37 +88,85 @@ export class NotificationController extends Controller { @Get("{notificationId}") @Security("keycloak") async getNotification(@Request() req: RequestWithUser, @Path() notificationId: string) { - // TODO: implement + const record = await prisma.notification.update({ + where: { id: notificationId }, + data: { + readByUser: { + connect: { id: req.user.sub }, + }, + }, + }); - return {}; + if (!record) throw notFoundError("Notification"); + + return record; } - @Post() + @Post("mark-read") @Security("keycloak") - async createNotification(@Request() req: RequestWithUser, @Body() body: NotificationCreate) { - // TODO: implement + async markRead(@Request() req: RequestWithUser, @Body() body?: { id: string[] }) { + const record = await prisma.notification.findMany({ + where: { + id: body ? { in: body.id } : undefined, + OR: !body + ? [ + { receiverId: req.user.sub }, + req.user.roles.length > 0 + ? { + groupReceiver: { some: { name: { in: req.user.roles } } }, + registeredBranch: { OR: permissionCondCompany(req.user) }, + } + : {}, + ] + : undefined, + }, + }); - this.setStatus(HttpStatus.CREATED); - return {}; + await prisma.$transaction( + record.map((v) => + prisma.notification.update({ + where: { id: v.id }, + data: { + readByUser: { connect: { id: req.user.sub } }, + }, + }), + ), + ); } - @Put("{notificationId}") + @Delete() @Security("keycloak") - async updateNotification( - @Request() req: RequestWithUser, - @Path() notificationId: string, - @Body() body: NotificationUpdate, - ) { - // TODO: implement + async deleteNotificationMany(@Request() req: RequestWithUser, @Body() notificationId: string[]) { + if (!notificationId.length) return; - return {}; + return await prisma.notification + .findMany({ where: { id: { in: notificationId } } }) + .then(async (v) => { + await prisma.$transaction( + v.map((v) => + prisma.notification.update({ + where: { id: v.id }, + data: { + deleteByUser: { connect: { id: req.user.sub } }, + }, + }), + ), + ); + }); } @Delete("{notificationId}") @Security("keycloak") async deleteNotification(@Request() req: RequestWithUser, @Path() notificationId: string) { - // TODO: implement - - return {}; + const record = await prisma.notification.findFirst({ where: { id: notificationId } }); + if (!record) throw notFoundError("Notification"); + return await prisma.notification.update({ + where: { id: notificationId }, + data: { + deleteByUser: { + connect: { id: req.user.sub }, + }, + }, + }); } } diff --git a/src/controllers/00-stats-controller.ts b/src/controllers/00-stats-controller.ts new file mode 100644 index 0000000..668f1e7 --- /dev/null +++ b/src/controllers/00-stats-controller.ts @@ -0,0 +1,566 @@ +import config from "../config.json"; +import { + Customer, + CustomerBranch, + ProductGroup, + QuotationStatus, + RequestWorkStatus, + User, +} from "@prisma/client"; +import { Controller, Get, Query, Request, Route, Security, Tags } from "tsoa"; +import prisma from "../db"; +import { createPermCondition } from "../services/permission"; +import { RequestWithUser } from "../interfaces/user"; +import { PaymentStatus } from "../generated/kysely/types"; +import { precisionRound } from "../utils/arithmetic"; +import dayjs from "dayjs"; +import { json2csv } from "json-2-csv"; + +const permissionCondCompany = createPermCondition((_) => true); + +const VAT_DEFAULT = config.vat; + +@Route("/api/v1/report") +@Security("keycloak") +@Tags("Report") +export class StatsController extends Controller { + @Get("quotation/download") + async downloadQuotationReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + this.setHeader("Content-Type", "text/csv"); + return json2csv(await this.quotationReport(req, limit, startDate, endDate), { + useDateIso8601Format: true, + }); + } + + @Get("quotation") + async quotationReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + const record = await prisma.quotation.findMany({ + select: { + code: true, + quotationStatus: true, + createdAt: true, + updatedAt: true, + }, + where: { + registeredBranch: { OR: permissionCondCompany(req.user) }, + createdAt: { gte: startDate, lte: endDate }, + }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + + return record.map((v) => ({ + document: "quotation", + code: v.code, + status: v.quotationStatus, + createdAt: v.createdAt, + updatedAt: v.updatedAt, + })); + } + + @Get("invoice/download") + async downloadInvoiceReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + this.setHeader("Content-Type", "text/csv"); + return json2csv(await this.invoiceReport(req, limit, startDate, endDate), { + useDateIso8601Format: true, + }); + } + + @Get("invoice") + async invoiceReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + const record = await prisma.invoice.findMany({ + select: { + code: true, + payment: { + select: { + paymentStatus: true, + }, + }, + amount: true, + createdAt: true, + }, + where: { + quotation: { + isDebitNote: false, + registeredBranch: { OR: permissionCondCompany(req.user) }, + }, + createdAt: { gte: startDate, lte: endDate }, + }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + + return record.map((v) => ({ + document: "invoice", + code: v.code, + status: v.payment?.paymentStatus, + amount: v.amount, + createdAt: v.createdAt, + })); + } + + @Get("receipt/download") + async downloadReceiptReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + this.setHeader("Content-Type", "text/csv"); + return json2csv(await this.receiptReport(req, limit, startDate, endDate), { + useDateIso8601Format: true, + }); + } + + @Get("receipt") + async receiptReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + const record = await prisma.payment.findMany({ + select: { + code: true, + paymentStatus: true, + createdAt: true, + }, + where: { + paymentStatus: PaymentStatus.PaymentSuccess, + invoice: { + quotation: { + isDebitNote: false, + registeredBranch: { OR: permissionCondCompany(req.user) }, + }, + }, + createdAt: { gte: startDate, lte: endDate }, + }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + + return record.map((v) => ({ + document: "receipt", + code: v.code, + status: v.paymentStatus, + createdAt: v.createdAt, + })); + } + + @Get("product/download") + async downloadProductReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + this.setHeader("Content-Type", "text/csv"); + return json2csv(await this.productReport(req, limit, startDate, endDate), { + useDateIso8601Format: true, + }); + } + + @Get("product") + async productReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + return await prisma.$transaction(async (tx) => { + const record = await tx.product.findMany({ + select: { + id: true, + code: true, + name: true, + createdAt: true, + updatedAt: true, + quotationProductServiceList: { + include: { quotation: true }, + }, + _count: { + select: { + quotationProductServiceList: { + where: { + quotation: { + quotationStatus: { + in: [ + QuotationStatus.PaymentInProcess, + QuotationStatus.PaymentSuccess, + QuotationStatus.ProcessComplete, + ], + }, + }, + }, + }, + }, + }, + }, + where: { + quotationProductServiceList: { + some: { + quotation: { createdAt: { gte: startDate, lte: endDate } }, + }, + }, + productGroup: { registeredBranch: { OR: permissionCondCompany(req.user) } }, + }, + orderBy: { + quotationProductServiceList: { _count: "desc" }, + }, + take: limit, + }); + + const doing = await tx.quotationProductServiceList.groupBy({ + _count: true, + by: "productId", + where: { + quotation: { + createdAt: { gte: startDate, lte: endDate }, + registeredBranch: { OR: permissionCondCompany(req.user) }, + }, + productId: { in: record.map((v) => v.id) }, + requestWork: { + some: { + stepStatus: { + some: { + workStatus: { + in: [ + RequestWorkStatus.Pending, + RequestWorkStatus.InProgress, + RequestWorkStatus.Validate, + RequestWorkStatus.Completed, + RequestWorkStatus.Ended, + ], + }, + }, + }, + }, + }, + }, + }); + + const order = await tx.quotationProductServiceList.groupBy({ + _count: true, + by: "productId", + where: { + quotation: { + createdAt: { gte: startDate, lte: endDate }, + registeredBranch: { OR: permissionCondCompany(req.user) }, + }, + productId: { in: record.map((v) => v.id) }, + }, + }); + + return record.map((v) => ({ + document: "product", + code: v.code, + name: v.name, + sale: v._count.quotationProductServiceList, + did: doing.find((item) => item.productId === v.id)?._count || 0, + order: order.find((item) => item.productId === v.id)?._count || 0, + createdAt: v.createdAt, + updatedAt: v.updatedAt, + })); + }); + } + + @Get("sale/by-product-group/download") + async downloadSaleByProductGroupReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + this.setHeader("Content-Type", "text/csv"); + return json2csv( + await this.saleReport(req, limit, startDate, endDate).then((v) => v.byProductGroup), + { useDateIso8601Format: true }, + ); + } + + @Get("sale/by-sale/download") + async downloadSaleBySaleReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + this.setHeader("Content-Type", "text/csv"); + return json2csv(await this.saleReport(req, limit, startDate, endDate).then((v) => v.bySale), { + useDateIso8601Format: true, + }); + } + + @Get("sale/by-customer/download") + async downloadSaleByCustomerReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + this.setHeader("Content-Type", "text/csv"); + return json2csv( + await this.saleReport(req, limit, startDate, endDate).then((v) => v.byCustomer), + { useDateIso8601Format: true }, + ); + } + + @Get("sale") + async saleReport( + @Request() req: RequestWithUser, + @Query() limit?: number, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + const list = await prisma.quotationProductServiceList.findMany({ + include: { + quotation: { + include: { + createdBy: true, + customerBranch: { + include: { customer: true }, + }, + }, + }, + product: { + include: { + productGroup: true, + }, + }, + }, + where: { + quotation: { + isDebitNote: false, + registeredBranch: { OR: permissionCondCompany(req.user) }, + createdAt: { gte: startDate, lte: endDate }, + quotationStatus: { + in: [ + QuotationStatus.PaymentInProcess, + QuotationStatus.PaymentSuccess, + QuotationStatus.ProcessComplete, + ], + }, + }, + }, + take: limit, + }); + + return list.reduce<{ + byProductGroup: (ProductGroup & { _count: number })[]; + bySale: (User & { _count: number })[]; + byCustomer: ((CustomerBranch & { customer: Customer }) & { _count: number })[]; + }>( + (a, c) => { + { + const found = a.byProductGroup.find((v) => v.id === c.product.productGroupId); + if (found) { + found._count++; + } else { + a.byProductGroup.push({ ...c.product.productGroup, _count: 1 }); + } + } + + { + const found = a.bySale.find((v) => v.id === c.quotation.createdByUserId); + if (found) { + found._count++; + } else { + if (c.quotation.createdBy) { + a.bySale.push({ ...c.quotation.createdBy, _count: 1 }); + } + } + } + + { + const found = a.byCustomer.find((v) => v.id === c.quotation.customerBranchId); + if (found) { + found._count++; + } else { + a.byCustomer.push({ ...c.quotation.customerBranch, _count: 1 }); + } + } + + return a; + }, + { byProductGroup: [], bySale: [], byCustomer: [] }, + ); + } + + @Get("profit") + async profit( + @Request() req: RequestWithUser, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + const record = await prisma.quotationProductServiceList.findMany({ + include: { + work: { + include: { + productOnWork: { + select: { stepCount: true, productId: true }, + }, + }, + }, + product: { + select: { + agentPrice: true, + agentPriceCalcVat: true, + agentPriceVatIncluded: true, + serviceCharge: true, + serviceChargeCalcVat: true, + serviceChargeVatIncluded: true, + price: true, + calcVat: true, + vatIncluded: true, + }, + }, + requestWork: { + include: { + stepStatus: true, + creditNote: true, + }, + }, + quotation: { + select: { + agentPrice: true, + creditNote: true, + }, + }, + }, + where: { + quotation: { + quotationStatus: { + in: [ + QuotationStatus.PaymentInProcess, + QuotationStatus.PaymentSuccess, + QuotationStatus.ProcessComplete, + ], + }, + registeredBranch: { + OR: permissionCondCompany(req.user), + }, + createdAt: { gte: startDate, lte: endDate }, + }, + }, + }); + + const data = record.map((v) => { + const originalPrice = v.product.serviceCharge; + const productExpenses = precisionRound( + originalPrice + (v.product.serviceChargeVatIncluded ? 0 : originalPrice * VAT_DEFAULT), + ); + const finalPrice = v.pricePerUnit * v.amount * (1 + config.vat); + + return v.requestWork.map((w) => { + const creditNote = w.creditNote; + const roundCount = v.work?.productOnWork.find((p) => p.productId)?.stepCount || 1; + const successCount = w.stepStatus.filter( + (s) => s.workStatus !== RequestWorkStatus.Canceled, + ).length; + + const income = creditNote + ? precisionRound(productExpenses * successCount) + : precisionRound(finalPrice); + const expenses = creditNote + ? precisionRound(productExpenses * successCount) + : precisionRound(productExpenses * roundCount); + const netProfit = creditNote ? 0 : precisionRound(finalPrice - expenses); + + return { + income, + expenses, + netProfit, + }; + }); + }); + + return data.flat().reduce( + (a, c) => { + a.income += c.income; + a.expenses += c.expenses; + a.netProfit += c.netProfit; + return a; + }, + { income: 0, expenses: 0, netProfit: 0 }, + ); + } + + @Get("payment") + async invoice( + @Request() req: RequestWithUser, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { + if (!startDate && !endDate) { + startDate = dayjs(new Date()).subtract(12, "months").startOf("month").toDate(); + endDate = dayjs(new Date()).endOf("months").toDate(); + } + + if (!startDate && endDate) { + startDate = dayjs(endDate).subtract(12, "months").startOf("month").toDate(); + } + + if (startDate && !endDate) { + endDate = dayjs(new Date()).endOf("month").toDate(); + } + + const data = await prisma.$transaction(async (tx) => { + const months: Date[] = []; + + while (startDate! < endDate!) { + months.push(startDate!); + startDate = dayjs(startDate).startOf("month").add(1, "month").toDate(); + } + + return await Promise.all( + months.map(async (v) => { + const date = dayjs(v); + return { + month: date.format("MM"), + year: date.format("YYYY"), + data: await tx.payment + .groupBy({ + _sum: { amount: true }, + where: { + createdAt: { gte: v, lte: date.endOf("month").toDate() }, + invoice: { + quotation: { + registeredBranch: { OR: permissionCondCompany(req.user) }, + }, + }, + }, + by: "paymentStatus", + }) + .then((v) => + v.reduce>>((a, c) => { + a[c.paymentStatus] = c._sum.amount || 0; + return a; + }, {}), + ), + }; + }), + ); + }); + return data; + } +} diff --git a/src/controllers/04-product-controller.ts b/src/controllers/04-product-controller.ts index b73bd3a..6e07f98 100644 --- a/src/controllers/04-product-controller.ts +++ b/src/controllers/04-product-controller.ts @@ -374,6 +374,7 @@ export class ProductController extends Controller { const record = await prisma.product.update({ include: { + productGroup: true, createdBy: true, updatedBy: true, }, @@ -398,6 +399,17 @@ export class ProductController extends Controller { }); } + await prisma.notification.create({ + data: { + title: "สินค้ามีการเปลี่ยนแปลง / Product Updated", + detail: "รหัส / code : " + record.code, + groupReceiver: { + create: [{ name: "sale" }, { name: "head_of_sale" }], + }, + registeredBranchId: record.productGroup.registeredBranchId, + }, + }); + return record; } diff --git a/src/controllers/04-service-controller.ts b/src/controllers/04-service-controller.ts index c48d948..0670295 100644 --- a/src/controllers/04-service-controller.ts +++ b/src/controllers/04-service-controller.ts @@ -473,6 +473,7 @@ export class ServiceController extends Controller { return await tx.service.update({ include: { + productGroup: true, createdBy: true, updatedBy: true, }, @@ -523,6 +524,17 @@ export class ServiceController extends Controller { }); }); + await prisma.notification.create({ + data: { + title: "แพคเกจมีการเปลี่ยนแปลง / Package Updated", + detail: "รหัส / code : " + record.code, + groupReceiver: { + create: [{ name: "sale" }, { name: "head_of_sale" }], + }, + registeredBranchId: record.productGroup.registeredBranchId, + }, + }); + return record; } diff --git a/src/controllers/05-payment-controller.ts b/src/controllers/05-payment-controller.ts index 1177dba..a4b7339 100644 --- a/src/controllers/05-payment-controller.ts +++ b/src/controllers/05-payment-controller.ts @@ -177,55 +177,66 @@ export class QuotationPayment extends Controller { }, }); - await tx.quotation.update({ - where: { id: quotation.id }, - data: { - quotationStatus: - (paymentSum._sum.amount || 0) >= quotation.finalPrice - ? "PaymentSuccess" - : "PaymentInProcess", - requestData: await (async () => { - if ( - body.paymentStatus === "PaymentSuccess" && - (paymentSum._sum.amount || 0) - payment.amount <= 0 - ) { - const lastRequest = await tx.runningNo.upsert({ - where: { - key: `REQUEST_${year}${month}`, - }, - create: { - key: `REQUEST_${year}${month}`, - value: quotation.worker.length, - }, - update: { value: { increment: quotation.worker.length } }, - }); - return { - create: quotation.worker.flatMap((v, i) => { - const productEmployee = quotation.productServiceList.flatMap((item) => - item.worker.findIndex((w) => w.employeeId === v.employeeId) !== -1 - ? { productServiceId: item.id } - : [], - ); + await tx.quotation + .update({ + where: { id: quotation.id }, + data: { + quotationStatus: + (paymentSum._sum.amount || 0) >= quotation.finalPrice + ? "PaymentSuccess" + : "PaymentInProcess", + requestData: await (async () => { + if ( + body.paymentStatus === "PaymentSuccess" && + (paymentSum._sum.amount || 0) - payment.amount <= 0 + ) { + const lastRequest = await tx.runningNo.upsert({ + where: { + key: `REQUEST_${year}${month}`, + }, + create: { + key: `REQUEST_${year}${month}`, + value: quotation.worker.length, + }, + update: { value: { increment: quotation.worker.length } }, + }); + return { + create: quotation.worker.flatMap((v, i) => { + const productEmployee = quotation.productServiceList.flatMap((item) => + item.worker.findIndex((w) => w.employeeId === v.employeeId) !== -1 + ? { productServiceId: item.id } + : [], + ); - if (productEmployee.length <= 0) return []; + if (productEmployee.length <= 0) return []; - return { - code: `TR${year}${month}${(lastRequest.value - quotation.worker.length + i + 1).toString().padStart(6, "0")}`, - employeeId: v.employeeId, - requestWork: { - create: quotation.productServiceList.flatMap((item) => - item.worker.findIndex((w) => w.employeeId === v.employeeId) !== -1 - ? { productServiceId: item.id } - : [], - ), - }, - }; - }), - }; - } - })(), - }, - }); + return { + code: `TR${year}${month}${(lastRequest.value - quotation.worker.length + i + 1).toString().padStart(6, "0")}`, + employeeId: v.employeeId, + requestWork: { + create: quotation.productServiceList.flatMap((item) => + item.worker.findIndex((w) => w.employeeId === v.employeeId) !== -1 + ? { productServiceId: item.id } + : [], + ), + }, + }; + }), + }; + } + })(), + }, + }) + .then(async (res) => { + if (quotation.quotationStatus !== res.quotationStatus) + await tx.notification.create({ + data: { + title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", + detail: "รหัส / code : " + res.code + " " + res.quotationStatus, + receiverId: res.createdByUserId, + }, + }); + }); return payment; }); diff --git a/src/controllers/05-quotation-controller.ts b/src/controllers/05-quotation-controller.ts index 9ae2ae1..d60d6a5 100644 --- a/src/controllers/05-quotation-controller.ts +++ b/src/controllers/05-quotation-controller.ts @@ -168,13 +168,21 @@ const permissionCond = createPermCondition(globalAllow); export class QuotationController extends Controller { @Get("stats") @Security("keycloak") - async getProductStats(@Request() req: RequestWithUser) { + async getQuotationStats( + @Request() req: RequestWithUser, + @Query() startDate?: Date, + @Query() endDate?: Date, + ) { const result = await prisma.quotation.groupBy({ _count: true, by: "quotationStatus", where: { registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, isDebitNote: false, + createdAt: { + gte: startDate, + lte: endDate, + }, }, }); @@ -454,7 +462,7 @@ export class QuotationController extends Controller { const { productServiceList: _productServiceList, worker: _worker, ...rest } = body; - return await prisma.$transaction(async (tx) => { + const ret = await prisma.$transaction(async (tx) => { const nonExistEmployee = body.worker.filter((v) => typeof v !== "string"); const lastEmployee = await tx.runningNo.upsert({ where: { @@ -639,6 +647,17 @@ export class QuotationController extends Controller { }, }); }); + + await prisma.notification.create({ + data: { + title: "ใบเสนอราคาใหม่ / New Quotation", + detail: "รหัส / code : " + ret.code, + registeredBranchId: ret.registeredBranchId, + groupReceiver: { create: [{ name: "accountant" }, { name: "head_of_accountant" }] }, + }, + }); + + return ret; } @Put("{quotationId}") @@ -1125,41 +1144,53 @@ export class QuotationActionController extends Controller { }, update: { value: { increment: quotation.worker.length } }, }); - await tx.quotation.update({ - where: { id: quotationId, isDebitNote: false }, - data: { - quotationStatus: QuotationStatus.PaymentSuccess, // NOTE: change back if already complete or canceled - worker: { - createMany: { - data: rearrange - .filter((lhs) => !quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId)) - .map((v, i) => ({ - no: quotation._count.worker + i + 1, - employeeId: v.workerId, - })), + await tx.quotation + .update({ + include: { requestData: true }, + where: { id: quotationId, isDebitNote: false }, + data: { + quotationStatus: QuotationStatus.PaymentSuccess, // NOTE: change back if already complete or canceled + worker: { + createMany: { + data: rearrange + .filter((lhs) => !quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId)) + .map((v, i) => ({ + no: quotation._count.worker + i + 1, + employeeId: v.workerId, + })), + }, }, + requestData: + quotation.quotationStatus === "PaymentInProcess" || + quotation.quotationStatus === "PaymentSuccess" + ? { + create: rearrange + .filter( + (lhs) => + !quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId) && + lhs.productServiceId.length > 0, + ) + .map((v, i) => ({ + code: `TR${year}${month}${(lastRequest.value - quotation._count.worker + i + 1).toString().padStart(6, "0")}`, + employeeId: v.workerId, + requestWork: { + create: v.productServiceId.map((v) => ({ productServiceId: v })), + }, + })), + } + : undefined, }, - requestData: - quotation.quotationStatus === "PaymentInProcess" || - quotation.quotationStatus === "PaymentSuccess" - ? { - create: rearrange - .filter( - (lhs) => - !quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId) && - lhs.productServiceId.length > 0, - ) - .map((v, i) => ({ - code: `TR${year}${month}${(lastRequest.value - quotation._count.worker + i + 1).toString().padStart(6, "0")}`, - employeeId: v.workerId, - requestWork: { - create: v.productServiceId.map((v) => ({ productServiceId: v })), - }, - })), - } - : undefined, - }, - }); + }) + .then(async (ret) => { + await prisma.notification.create({ + data: { + title: "รายการคำขอใหม่ / New Request", + detail: "รหัส / code : " + ret.requestData.map((v) => v.code).join(", "), + registeredBranchId: ret.registeredBranchId, + groupReceiver: { create: { name: "document_checker" } }, + }, + }); + }); }); } } diff --git a/src/controllers/06-request-list-controller.ts b/src/controllers/06-request-list-controller.ts index bbb2e39..b0d78e9 100644 --- a/src/controllers/06-request-list-controller.ts +++ b/src/controllers/06-request-list-controller.ts @@ -30,6 +30,8 @@ import { import { queryOrNot } from "../utils/relation"; import { notFoundError } from "../utils/error"; import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio"; +import HttpError from "../interfaces/http-error"; +import HttpStatus from "../interfaces/http-status"; // User in company can edit. const permissionCheck = createPermCheck((_) => true); @@ -268,14 +270,24 @@ export class RequestDataActionController extends Controller { }), ]); await Promise.all([ - tx.quotation.updateMany({ - where: { - requestData: { - every: { requestDataStatus: RequestDataStatus.Canceled }, + tx.quotation + .updateManyAndReturn({ + where: { + requestData: { + every: { requestDataStatus: RequestDataStatus.Canceled }, + }, }, - }, - data: { quotationStatus: QuotationStatus.Canceled, urgent: false }, - }), + data: { quotationStatus: QuotationStatus.Canceled, urgent: false }, + }) + .then(async (res) => { + await tx.notification.createMany({ + data: res.map((v) => ({ + title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", + detail: "รหัส / code : " + v.code + " Canceled", + receiverId: v.createdByUserId, + })), + }); + }), tx.taskOrder.updateMany({ where: { taskList: { @@ -405,14 +417,25 @@ export class RequestDataActionController extends Controller { data: { taskStatus: TaskStatus.Canceled }, }); await Promise.all([ - tx.quotation.updateMany({ - where: { - requestData: { - every: { requestDataStatus: RequestDataStatus.Canceled }, + tx.quotation + .updateManyAndReturn({ + where: { + quotationStatus: { not: QuotationStatus.Canceled }, + requestData: { + every: { requestDataStatus: RequestDataStatus.Canceled }, + }, }, - }, - data: { quotationStatus: QuotationStatus.Canceled, urgent: false }, - }), + data: { quotationStatus: QuotationStatus.Canceled, urgent: false }, + }) + .then(async (res) => { + await tx.notification.createMany({ + data: res.map((v) => ({ + title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", + detail: "รหัส / code : " + v.code + " Canceled", + receiverId: v.createdByUserId, + })), + }); + }), tx.taskOrder.updateMany({ where: { taskList: { @@ -479,21 +502,31 @@ export class RequestDataActionController extends Controller { where: { id: { in: completed } }, data: { requestDataStatus: RequestDataStatus.Completed }, }); - await tx.quotation.updateMany({ - where: { - quotationStatus: { - notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete], - }, - requestData: { - every: { - requestDataStatus: { - in: [RequestDataStatus.Canceled, RequestDataStatus.Completed], + await tx.quotation + .updateManyAndReturn({ + where: { + quotationStatus: { + notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete], + }, + requestData: { + every: { + requestDataStatus: { + in: [RequestDataStatus.Canceled, RequestDataStatus.Completed], + }, }, }, }, - }, - data: { quotationStatus: QuotationStatus.ProcessComplete, urgent: false }, - }); + data: { quotationStatus: QuotationStatus.ProcessComplete, urgent: false }, + }) + .then(async (res) => { + await tx.notification.createMany({ + data: res.map((v) => ({ + title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", + detail: "รหัส / code : " + v.code + " Completed", + receiverId: v.createdByUserId, + })), + }); + }); // dataRecord.push(record); return data; }); @@ -503,6 +536,14 @@ export class RequestDataActionController extends Controller { @Route("/api/v1/request-work") @Tags("Request List") export class RequestListController 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() @Security("keycloak") async getRequestWork( @@ -812,14 +853,25 @@ export class RequestListController extends Controller { data: { taskStatus: TaskStatus.Canceled }, }); await Promise.all([ - tx.quotation.updateMany({ - where: { - requestData: { - every: { requestDataStatus: RequestDataStatus.Canceled }, + tx.quotation + .updateManyAndReturn({ + where: { + quotationStatus: { not: QuotationStatus.Canceled }, + requestData: { + every: { requestDataStatus: RequestDataStatus.Canceled }, + }, }, - }, - data: { quotationStatus: QuotationStatus.Canceled, urgent: false }, - }), + data: { quotationStatus: QuotationStatus.Canceled, urgent: false }, + }) + .then(async (res) => { + await tx.notification.createMany({ + data: res.map((v) => ({ + title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", + detail: "รหัส / code : " + v.code + " Canceled", + receiverId: v.createdByUserId, + })), + }); + }), tx.taskOrder.updateMany({ where: { taskList: { @@ -887,19 +939,94 @@ export class RequestListController extends Controller { where: { id: { in: completed } }, data: { requestDataStatus: RequestDataStatus.Completed }, }); - await tx.quotation.updateMany({ - where: { - quotationStatus: { - notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete], - }, - requestData: { - every: { - requestDataStatus: { in: [RequestDataStatus.Canceled, RequestDataStatus.Completed] }, + await tx.quotation + .updateManyAndReturn({ + where: { + quotationStatus: { + notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete], + }, + requestData: { + every: { + requestDataStatus: { + in: [RequestDataStatus.Canceled, RequestDataStatus.Completed], + }, + }, }, }, - }, - data: { quotationStatus: QuotationStatus.ProcessComplete, urgent: false }, - }); + data: { quotationStatus: QuotationStatus.ProcessComplete, urgent: false }, + include: { + customerBranch: { + include: { + customer: { + include: { + branch: { + where: { userId: { not: null } }, + }, + }, + }, + }, + }, + }, + }) + .then(async (res) => { + await tx.notification.createMany({ + data: res.map((v) => ({ + title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", + detail: "รหัส / code : " + v.code + " Completed", + receiverId: v.createdByUserId, + })), + }); + 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.forEach((data, index) => { + data.customerBranch.customer.branch.forEach((item) => { + if (!dataCustomerId?.includes(item.id) && item.userId) { + dataCustomerId.push(item.id); + dataUserId.push(item.userId); + } + }); + textWorkList.push(`${index + 1}. เลขที่ใบเสนอราคา ${data.code} ${data.workName}`); + }); + + finalTextWork = textWorkList.join("\n"); + } + + textData = `${textHead}\n\n${textAlert}\n${finalTextWork}\n${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 record; }); } diff --git a/src/controllers/07-task-controller.ts b/src/controllers/07-task-controller.ts index a572962..d4bcfb8 100644 --- a/src/controllers/07-task-controller.ts +++ b/src/controllers/07-task-controller.ts @@ -433,88 +433,101 @@ export class TaskController extends Controller { ); } - return await prisma.$transaction(async (tx) => { - await Promise.all( - record.taskList - .filter( - (lhs) => - !body.taskList.find( - (rhs) => lhs.requestWorkId === rhs.requestWorkId && lhs.step === rhs.step, - ), - ) - .map((v) => - tx.task.update({ - where: { id: v.id }, - data: { - requestWorkStep: { update: { workStatus: "Ready" } }, - }, - }), - ), - ); + return await prisma + .$transaction(async (tx) => { + await Promise.all( + record.taskList + .filter( + (lhs) => + !body.taskList.find( + (rhs) => lhs.requestWorkId === rhs.requestWorkId && lhs.step === rhs.step, + ), + ) + .map((v) => + tx.task.update({ + where: { id: v.id }, + data: { + requestWorkStep: { update: { workStatus: "Ready" } }, + }, + }), + ), + ); - await tx.requestWorkStepStatus.updateMany({ - where: { - OR: body.taskList, - workStatus: RequestWorkStatus.Ready, - }, - data: { workStatus: RequestWorkStatus.InProgress }, - }); - - const work = await tx.requestWorkStepStatus.findMany({ - include: { - requestWork: { - include: { - request: { - include: { quotation: true }, - }, - }, + await tx.requestWorkStepStatus.updateMany({ + where: { + OR: body.taskList, + workStatus: RequestWorkStatus.Ready, }, - }, - where: { OR: body.taskList }, - }); + data: { workStatus: RequestWorkStatus.InProgress }, + }); - return await tx.taskOrder.update({ - where: { id: taskOrderId }, - include: { - taskList: { - include: { - requestWorkStep: { - include: { - requestWork: true, + const work = await tx.requestWorkStepStatus.findMany({ + include: { + requestWork: { + include: { + request: { + include: { quotation: true }, }, }, }, }, - institution: true, - registeredBranch: true, - createdBy: true, - }, - data: { - ...body, - urgent: work.some((v) => v.requestWork.request.quotation.urgent), - taskList: { - deleteMany: record?.taskList - .filter( - (lhs) => - !body.taskList.find( - (rhs) => lhs.requestWorkId === rhs.requestWorkId && lhs.step === rhs.step, - ), - ) - .map((v) => ({ id: v.id })), - createMany: { - data: body.taskList.filter( - (lhs) => - !record?.taskList.find( - (rhs) => lhs.requestWorkId === rhs.requestWorkId && lhs.step === rhs.step, - ), - ), - skipDuplicates: true, + where: { OR: body.taskList }, + }); + + return await tx.taskOrder.update({ + where: { id: taskOrderId }, + include: { + taskList: { + include: { + requestWorkStep: { + include: { + requestWork: true, + }, + }, + }, }, + institution: true, + registeredBranch: true, + createdBy: true, }, - taskProduct: { deleteMany: {}, create: body.taskProduct }, - }, + data: { + ...body, + urgent: work.some((v) => v.requestWork.request.quotation.urgent), + taskList: { + deleteMany: record?.taskList + .filter( + (lhs) => + !body.taskList.find( + (rhs) => lhs.requestWorkId === rhs.requestWorkId && lhs.step === rhs.step, + ), + ) + .map((v) => ({ id: v.id })), + createMany: { + data: body.taskList.filter( + (lhs) => + !record?.taskList.find( + (rhs) => lhs.requestWorkId === rhs.requestWorkId && lhs.step === rhs.step, + ), + ), + skipDuplicates: true, + }, + }, + taskProduct: { deleteMany: {}, create: body.taskProduct }, + }, + }); + }) + .then(async (ret) => { + if (body.taskOrderStatus && record.taskOrderStatus !== body.taskOrderStatus) { + await prisma.notification.create({ + data: { + title: "มีการส่งงาน / Task Submitted", + detail: "รหัสใบสั่งงาน / Order : " + record.code, + receiverId: record.createdByUserId, + }, + }); + } + return ret; }); - }); } @Delete("{taskOrderId}") @@ -560,6 +573,14 @@ export class TaskController extends Controller { @Route("/api/v1/task-order/{taskOrderId}") @Tags("Task Order") export class TaskActionController 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; + } + @Post("set-task-status") @Security("keycloak") async changeTaskOrderTaskListStatus( @@ -651,6 +672,13 @@ export class TaskActionController extends Controller { }, data: { userTaskStatus: UserTaskStatus.Submit, submittedAt: new Date() }, }), + prisma.notification.create({ + data: { + title: "มีการส่งงาน / Task Submitted", + detail: "รหัสใบสั่งงาน / Order : " + record.code, + receiverId: record.createdByUserId, + }, + }), ]); } @@ -785,19 +813,95 @@ export class TaskActionController extends Controller { where: { id: { in: completed } }, data: { requestDataStatus: RequestDataStatus.Completed }, }); - await tx.quotation.updateMany({ - where: { - quotationStatus: { - notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete], - }, - requestData: { - every: { - requestDataStatus: { in: [RequestDataStatus.Canceled, RequestDataStatus.Completed] }, + await tx.quotation + .updateManyAndReturn({ + where: { + quotationStatus: { + notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete], + }, + requestData: { + every: { + requestDataStatus: { + in: [RequestDataStatus.Canceled, RequestDataStatus.Completed], + }, + }, }, }, - }, - data: { quotationStatus: QuotationStatus.ProcessComplete, urgent: false }, - }); + data: { quotationStatus: QuotationStatus.ProcessComplete, urgent: false }, + include: { + customerBranch: { + include: { + customer: { + include: { + branch: { + where: { userId: { not: null } }, + }, + }, + }, + }, + }, + }, + }) + .then(async (res) => { + await tx.notification.createMany({ + data: res.map((v) => ({ + title: "สถานะใบเสนอราคาเปลี่ยนแปลง / Quotation Status Updated", + detail: "รหัส / code : " + v.code + " Completed", + receiverId: v.createdByUserId, + })), + }); + + 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.forEach((data, index) => { + data.customerBranch.customer.branch.forEach((item) => { + if (!dataCustomerId?.includes(item.id) && item.userId) { + dataCustomerId.push(item.id); + dataUserId.push(item.userId); + } + }); + textWorkList.push(`${index + 1}. เลขที่ใบเสนอราคา ${data.code} ${data.workName}`); + }); + + finalTextWork = textWorkList.join("\n"); + } + + textData = `${textHead}\n\n${textAlert}\n${finalTextWork}\n${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), + }); + }); }); } } @@ -965,20 +1069,37 @@ export class UserTaskController extends Controller { await prisma.$transaction(async (tx) => { const promises = body.taskOrderId.flatMap((taskOrderId) => [ - tx.taskOrder.update({ - where: { id: taskOrderId }, - data: { - taskOrderStatus: TaskOrderStatus.InProgress, - userTask: { - deleteMany: { userId: req.user.sub }, - create: { - userId: req.user.sub, - userTaskStatus: UserTaskStatus.Accept, - acceptedAt: new Date(), + tx.taskOrder + .update({ + where: { id: taskOrderId }, + data: { + taskOrderStatus: TaskOrderStatus.InProgress, + userTask: { + deleteMany: { userId: req.user.sub }, + create: { + userId: req.user.sub, + userTaskStatus: UserTaskStatus.Accept, + acceptedAt: new Date(), + }, }, }, - }, - }), + }) + .then(async (v) => { + await tx.notification.createMany({ + data: [ + { + title: "สถานะใบส่งงานมีการเปลี่ยนแปลง / Order Status Changed", + detail: "รหัสใบสั่งงาน / Order : " + v.code + " InProgress", + receiverId: v.createdByUserId, + }, + { + title: "มีการรับงาน / Task Accepted", + detail: "รหัสใบสั่งงาน / Order : " + v.code, + receiverId: v.createdByUserId, + }, + ], + }); + }), tx.task.updateMany({ where: { taskOrderId: taskOrderId, diff --git a/src/controllers/09-line-controller.ts b/src/controllers/09-line-controller.ts index 7704c4d..4e4910d 100644 --- a/src/controllers/09-line-controller.ts +++ b/src/controllers/09-line-controller.ts @@ -1,9 +1,11 @@ import { + Body, Controller, Delete, Get, Head, Path, + Post, Put, Query, Request, @@ -776,6 +778,70 @@ export class LineController extends Controller { return record; } + + @Post("request/{requestDataId}/request-cancel") + @Security("line") + async customerRequestCancel( + @Path() requestDataId: string, + @Request() req: RequestWithLineUser, + @Body() body: { reason: string }, + ) { + const result = await prisma.requestData.updateMany({ + where: { + id: requestDataId, + quotation: { + customerBranch: { + OR: [ + { userId: req.user.sub }, + { + customer: { + branch: { some: { userId: req.user.sub } }, + }, + }, + ], + }, + }, + }, + data: { + customerRequestCancel: true, + customerRequestCancelReason: body.reason, + }, + }); + if (result.count <= 0) throw notFoundError("Request Data"); + } + + @Post("request-work/{requestWorkId}/request-cancel") + @Security("line") + async customerRequestCancelWork( + @Path() requestWorkId: string, + @Request() req: RequestWithLineUser, + @Body() body: { reason: string }, + ) { + const result = await prisma.requestWork.updateMany({ + where: { + id: requestWorkId, + request: { + quotation: { + customerBranch: { + OR: [ + { userId: req.user.sub }, + { + customer: { + branch: { some: { userId: req.user.sub } }, + }, + }, + ], + }, + }, + }, + }, + data: { + customerRequestCancel: true, + customerRequestCancelReason: body.reason, + }, + }); + if (result.count <= 0) throw notFoundError("Request Data"); + } } @Route("api/v1/line/customer-branch/{branchId}") diff --git a/src/controllers/09-web-hook-controller.ts b/src/controllers/09-web-hook-controller.ts index 10587ef..14b7015 100644 --- a/src/controllers/09-web-hook-controller.ts +++ b/src/controllers/09-web-hook-controller.ts @@ -68,37 +68,37 @@ export class WebHookController extends Controller { const userIdLine = payload.events[0]?.source?.userId; const dataNow = dayjs().tz("Asia/Bangkok").startOf("day"); - // const dataUser = await prisma.customerBranch.findFirst({ - // where:{ - // userId:userIdLine - // } - // }) + if (payload?.events[0]?.message) { + const message = payload.events[0].message.text; - const dataEmployee = await prisma.employeePassport.findMany({ - select: { - firstName: true, - firstNameEN: true, - lastName: true, - lastNameEN: true, - employeeId: true, - expireDate: true, - employee: { + if (message === "เมนูหลัก > ข้อความ") { + const dataEmployee = await prisma.employeePassport.findMany({ select: { firstName: true, + firstNameEN: true, lastName: true, - customerBranch: { + lastNameEN: true, + employeeId: true, + expireDate: true, + employee: { select: { firstName: true, - firstNameEN: true, lastName: true, - lastNameEN: true, - customerName: true, - customer: { + customerBranch: { select: { - customerType: true, - registeredBranch: { + firstName: true, + firstNameEN: true, + lastName: true, + lastNameEN: true, + customerName: true, + customer: { select: { - telephoneNo: true, + customerType: true, + registeredBranch: { + select: { + telephoneNo: true, + }, + }, }, }, }, @@ -106,22 +106,28 @@ export class WebHookController extends Controller { }, }, }, - }, - }, - where: { - expireDate: { - lt: dataNow.add(30, "day").toDate(), - }, - }, - orderBy: { - expireDate: "asc", - }, - }); + where: { + employee: { + customerBranch: { + OR: [ + { userId: userIdLine }, + { + customer: { + branch: { some: { userId: userIdLine } }, + }, + }, + ], + }, + }, + expireDate: { + lt: dataNow.add(30, "day").toDate(), + }, + }, + orderBy: { + expireDate: "asc", + }, + }); - if (payload?.events[0]?.message) { - const message = payload.events[0].message.text; - - if (message === "เมนูหลัก > ข้อความ") { const dataUser = userIdLine; const textHead = "JWS ALERT:"; let textData = ""; @@ -147,7 +153,7 @@ export class WebHookController extends Controller { dayjs(item.expireDate).format("DD/MM/") + (dayjs(item.expireDate).year() + 543); const diffDate = dayjs(item.expireDate).diff(dayjs(), "day"); - return `${index + 1}. คุณ${item.firstName} ${item.lastName} วันหมดอายุเอกสาร : ${dateFormat} ใกล้หมดอายุอีก ${diffDate} วัน\n https://taii-cmm.case-collection.com/api/v1/line/employee/${item.employeeId}`; + return `${index + 1}. คุณ${item.firstName} ${item.lastName} วันหมดอายุเอกสาร : ${dateFormat} ใกล้หมดอายุอีก ${diffDate} วัน\n ${process.env.LINE_LIFF_URL}/${item.employeeId}`; }) .join("\n"); diff --git a/src/services/schedule.ts b/src/services/schedule.ts index df15f1f..bd12bf8 100644 --- a/src/services/schedule.ts +++ b/src/services/schedule.ts @@ -1,3 +1,4 @@ +import dayjs from "dayjs"; import { CronJob } from "cron"; import prisma from "../db"; @@ -25,6 +26,18 @@ const jobs = [ .catch((e) => console.error("[ERR]: Update expired quotation status, FAILED.", e)); }, }), + CronJob.from({ + cronTime: "0 0 0 * * *", + runOnInit: true, + onTick: async () => { + await prisma.notification + .deleteMany({ + where: { createdAt: { lte: dayjs().subtract(1, "month").toDate() } }, + }) + .then(() => console.log("[INFO]: Delete expired notification, OK.")) + .catch((e) => console.error("[ERR]: Update expired quotation status, FAILED.", e)); + }, + }), ]; export function initSchedule() { diff --git a/tsoa.json b/tsoa.json index 09db573..f9a463c 100644 --- a/tsoa.json +++ b/tsoa.json @@ -53,7 +53,9 @@ { "name": "Task Order" }, { "name": "User Task Order" }, { "name": "Credit Note" }, - { "name": "Debit Note" } + { "name": "Debit Note" }, + { "name": "Report" }, + { "name": "Document Template" } ] } },