Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 6s

This commit is contained in:
Methapon2001 2025-03-06 11:49:18 +07:00
commit 749d25b1cf
21 changed files with 1453 additions and 305 deletions

View file

@ -33,22 +33,14 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
tags: ${{ env.CONTAINER_IMAGE_NAME }} tags: ${{ env.CONTAINER_IMAGE_NAME }}
push: true push: true
- name: Remote Deploy Development - name: Remote Deploy
uses: appleboy/ssh-action@v1.2.1 uses: appleboy/ssh-action@v1.2.1
with: with:
host: ${{ vars.SSH_DEVELOPMENT_HOST }} host: ${{ vars.SSH_DEPLOY_HOST }}
port: ${{ vars.SSH_DEVELOPMENT_PORT }} port: ${{ vars.SSH_DEPLOY_PORT }}
username: ${{ secrets.SSH_DEVELOPMENT_USER }} username: ${{ secrets.SSH_DEPLOY_USER }}
password: ${{ secrets.SSH_DEVELOPMENT_PASSWORD }} password: ${{ secrets.SSH_DEPLOY_PASSWORD }}
script: eval "${{ secrets.SSH_DEVELOPMENT_DEPLOY_CMD }}" script: eval "${{ secrets.SSH_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 }}"
- name: Notify Discord Success - name: Notify Discord Success
if: success() if: success()
run: | run: |

View file

@ -3,8 +3,7 @@ name: Spell Check
permissions: permissions:
contents: read contents: read
on: on: [push, pull_request]
push:
env: env:
CLICOLOR: 1 CLICOLOR: 1

View file

@ -45,6 +45,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.21.2",
"fast-jwt": "^5.0.5", "fast-jwt": "^5.0.5",
"json-2-csv": "^5.5.8",
"kysely": "^0.27.5", "kysely": "^0.27.5",
"minio": "^8.0.2", "minio": "^8.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",

24
pnpm-lock.yaml generated
View file

@ -47,6 +47,9 @@ importers:
fast-jwt: fast-jwt:
specifier: ^5.0.5 specifier: ^5.0.5
version: 5.0.5 version: 5.0.5
json-2-csv:
specifier: ^5.5.8
version: 5.5.8
kysely: kysely:
specifier: ^0.27.5 specifier: ^0.27.5
version: 0.27.5 version: 0.27.5
@ -904,6 +907,10 @@ packages:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
deeks@3.1.0:
resolution: {integrity: sha512-e7oWH1LzIdv/prMQ7pmlDlaVoL64glqzvNgkgQNgyec9ORPHrT2jaOqMtRyqJuwWjtfb6v+2rk9pmaHj+F137A==}
engines: {node: '>= 16'}
define-data-property@1.1.4: define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -932,6 +939,10 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'} engines: {node: '>=8'}
doc-path@4.1.1:
resolution: {integrity: sha512-h1ErTglQAVv2gCnOpD3sFS6uolDbOKHDU1BZq+Kl3npPqroU3dYL42lUgMfd5UimlwtRgp7C9dLGwqQ5D2HYgQ==}
engines: {node: '>=16'}
docx-templates@4.13.0: docx-templates@4.13.0:
resolution: {integrity: sha512-tTmR3WhROYctuyVReQ+PfCU3zprmC45/VuSVzn8EjovzpRkXYUdXiDatB9M8pasj0V+wuuOyY8bcSHvlQ2GNag==} resolution: {integrity: sha512-tTmR3WhROYctuyVReQ+PfCU3zprmC45/VuSVzn8EjovzpRkXYUdXiDatB9M8pasj0V+wuuOyY8bcSHvlQ2GNag==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1535,6 +1546,10 @@ packages:
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} 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: json-bignum@0.0.3:
resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@ -3778,6 +3793,8 @@ snapshots:
decode-uri-component@0.2.2: {} decode-uri-component@0.2.2: {}
deeks@3.1.0: {}
define-data-property@1.1.4: define-data-property@1.1.4:
dependencies: dependencies:
es-define-property: 1.0.1 es-define-property: 1.0.1
@ -3811,6 +3828,8 @@ snapshots:
dependencies: dependencies:
path-type: 4.0.0 path-type: 4.0.0
doc-path@4.1.1: {}
docx-templates@4.13.0: docx-templates@4.13.0:
dependencies: dependencies:
jszip: 3.10.1 jszip: 3.10.1
@ -4568,6 +4587,11 @@ snapshots:
js-tokens@4.0.0: {} 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-bignum@0.0.3: {}
json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@2.3.1: {}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -21,12 +21,16 @@ model Notification {
groupReceiver NotificationGroup[] groupReceiver NotificationGroup[]
registeredBranchId String?
registeredBranch Branch? @relation(fields: [registeredBranchId], references: [id])
receiver User? @relation(name: "NotificationReceiver", fields: [receiverId], references: [id], onDelete: Cascade) receiver User? @relation(name: "NotificationReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
receiverId String? receiverId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
readByUser User[] readByUser User[] @relation(name: "NotificationRead")
deleteByUser User[] @relation(name: "NotificationDelete")
} }
model NotificationGroup { model NotificationGroup {
@ -313,6 +317,7 @@ model Branch {
quotation Quotation[] quotation Quotation[]
workflowTemplate WorkflowTemplate[] workflowTemplate WorkflowTemplate[]
taskOrder TaskOrder[] taskOrder TaskOrder[]
notification Notification[]
} }
model BranchBank { model BranchBank {
@ -480,7 +485,8 @@ model User {
invoiceCreated Invoice[] invoiceCreated Invoice[]
paymentCreated Payment[] paymentCreated Payment[]
notificationReceive Notification[] @relation("NotificationReceiver") notificationReceive Notification[] @relation("NotificationReceiver")
notificationRead Notification[] notificationRead Notification[] @relation("NotificationRead")
notificationDelete Notification[] @relation("NotificationDelete")
taskOrderCreated TaskOrder[] @relation("TaskOrderCreatedByUser") taskOrderCreated TaskOrder[] @relation("TaskOrderCreatedByUser")
creditNoteCreated CreditNote[] @relation("CreditNoteCreatedByUser") creditNoteCreated CreditNote[] @relation("CreditNoteCreatedByUser")
@ -1438,6 +1444,9 @@ model RequestData {
requestDataStatus RequestDataStatus @default(Pending) requestDataStatus RequestDataStatus @default(Pending)
customerRequestCancel Boolean?
customerRequestCancelReason String?
flow Json? flow Json?
requestWork RequestWork[] requestWork RequestWork[]
@ -1473,6 +1482,9 @@ model RequestWork {
stepStatus RequestWorkStepStatus[] stepStatus RequestWorkStepStatus[]
customerRequestCancel Boolean?
customerRequestCancelReason String?
creditNote CreditNote? @relation(fields: [creditNoteId], references: [id], onDelete: SetNull) creditNote CreditNote? @relation(fields: [creditNoteId], references: [id], onDelete: SetNull)
creditNoteId String? creditNoteId String?
} }

View file

@ -2,7 +2,7 @@ import createReport from "docx-templates";
import ThaiBahtText from "thai-baht-text"; import ThaiBahtText from "thai-baht-text";
import { District, Province, SubDistrict } from "@prisma/client"; import { District, Province, SubDistrict } from "@prisma/client";
import { Readable } from "node:stream"; 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 prisma from "../db";
import { notFoundError } from "../utils/error"; import { notFoundError } from "../utils/error";
import HttpError from "../interfaces/http-error"; import HttpError from "../interfaces/http-error";
@ -62,6 +62,7 @@ const quotationData = (id: string) =>
}); });
@Route("api/v1/doc-template") @Route("api/v1/doc-template")
@Tags("Document Template")
export class DocTemplateController extends Controller { export class DocTemplateController extends Controller {
@Get() @Get()
async getTemplate() { async getTemplate() {

View file

@ -5,7 +5,6 @@ import {
Get, Get,
Path, Path,
Post, Post,
Put,
Query, Query,
Request, Request,
Route, Route,
@ -13,10 +12,14 @@ import {
Tags, Tags,
} from "tsoa"; } from "tsoa";
import { RequestWithUser } from "../interfaces/user"; 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 = {}; const permissionCondCompany = createPermCondition((_) => true);
type NotificationUpdate = {};
@Route("/api/v1/notification") @Route("/api/v1/notification")
@Tags("Notification") @Tags("Notification")
@ -29,12 +32,53 @@ export class NotificationController extends Controller {
@Query() pageSize: number = 30, @Query() pageSize: number = 30,
@Query() query = "", @Query() query = "",
) { ) {
const total = 0; const where: Prisma.NotificationWhereInput = {
AND: [
// TODO: implement {
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 { 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, page,
pageSize, pageSize,
total, total,
@ -44,37 +88,85 @@ export class NotificationController extends Controller {
@Get("{notificationId}") @Get("{notificationId}")
@Security("keycloak") @Security("keycloak")
async getNotification(@Request() req: RequestWithUser, @Path() notificationId: string) { 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") @Security("keycloak")
async createNotification(@Request() req: RequestWithUser, @Body() body: NotificationCreate) { async markRead(@Request() req: RequestWithUser, @Body() body?: { id: string[] }) {
// TODO: implement 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); await prisma.$transaction(
return {}; record.map((v) =>
prisma.notification.update({
where: { id: v.id },
data: {
readByUser: { connect: { id: req.user.sub } },
},
}),
),
);
} }
@Put("{notificationId}") @Delete()
@Security("keycloak") @Security("keycloak")
async updateNotification( async deleteNotificationMany(@Request() req: RequestWithUser, @Body() notificationId: string[]) {
@Request() req: RequestWithUser, if (!notificationId.length) return;
@Path() notificationId: string,
@Body() body: NotificationUpdate,
) {
// TODO: implement
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}") @Delete("{notificationId}")
@Security("keycloak") @Security("keycloak")
async deleteNotification(@Request() req: RequestWithUser, @Path() notificationId: string) { async deleteNotification(@Request() req: RequestWithUser, @Path() notificationId: string) {
// TODO: implement const record = await prisma.notification.findFirst({ where: { id: notificationId } });
if (!record) throw notFoundError("Notification");
return {}; return await prisma.notification.update({
where: { id: notificationId },
data: {
deleteByUser: {
connect: { id: req.user.sub },
},
},
});
} }
} }

View file

@ -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<Partial<Record<(typeof v)[number]["paymentStatus"], number>>>((a, c) => {
a[c.paymentStatus] = c._sum.amount || 0;
return a;
}, {}),
),
};
}),
);
});
return data;
}
}

View file

@ -374,6 +374,7 @@ export class ProductController extends Controller {
const record = await prisma.product.update({ const record = await prisma.product.update({
include: { include: {
productGroup: true,
createdBy: true, createdBy: true,
updatedBy: 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; return record;
} }

View file

@ -473,6 +473,7 @@ export class ServiceController extends Controller {
return await tx.service.update({ return await tx.service.update({
include: { include: {
productGroup: true,
createdBy: true, createdBy: true,
updatedBy: 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; return record;
} }

View file

@ -177,55 +177,66 @@ export class QuotationPayment extends Controller {
}, },
}); });
await tx.quotation.update({ await tx.quotation
where: { id: quotation.id }, .update({
data: { where: { id: quotation.id },
quotationStatus: data: {
(paymentSum._sum.amount || 0) >= quotation.finalPrice quotationStatus:
? "PaymentSuccess" (paymentSum._sum.amount || 0) >= quotation.finalPrice
: "PaymentInProcess", ? "PaymentSuccess"
requestData: await (async () => { : "PaymentInProcess",
if ( requestData: await (async () => {
body.paymentStatus === "PaymentSuccess" && if (
(paymentSum._sum.amount || 0) - payment.amount <= 0 body.paymentStatus === "PaymentSuccess" &&
) { (paymentSum._sum.amount || 0) - payment.amount <= 0
const lastRequest = await tx.runningNo.upsert({ ) {
where: { const lastRequest = await tx.runningNo.upsert({
key: `REQUEST_${year}${month}`, where: {
}, key: `REQUEST_${year}${month}`,
create: { },
key: `REQUEST_${year}${month}`, create: {
value: quotation.worker.length, key: `REQUEST_${year}${month}`,
}, value: quotation.worker.length,
update: { value: { increment: quotation.worker.length } }, },
}); update: { value: { increment: quotation.worker.length } },
return { });
create: quotation.worker.flatMap((v, i) => { return {
const productEmployee = quotation.productServiceList.flatMap((item) => create: quotation.worker.flatMap((v, i) => {
item.worker.findIndex((w) => w.employeeId === v.employeeId) !== -1 const productEmployee = quotation.productServiceList.flatMap((item) =>
? { productServiceId: item.id } item.worker.findIndex((w) => w.employeeId === v.employeeId) !== -1
: [], ? { productServiceId: item.id }
); : [],
);
if (productEmployee.length <= 0) return []; if (productEmployee.length <= 0) return [];
return { return {
code: `TR${year}${month}${(lastRequest.value - quotation.worker.length + i + 1).toString().padStart(6, "0")}`, code: `TR${year}${month}${(lastRequest.value - quotation.worker.length + i + 1).toString().padStart(6, "0")}`,
employeeId: v.employeeId, employeeId: v.employeeId,
requestWork: { requestWork: {
create: quotation.productServiceList.flatMap((item) => create: quotation.productServiceList.flatMap((item) =>
item.worker.findIndex((w) => w.employeeId === v.employeeId) !== -1 item.worker.findIndex((w) => w.employeeId === v.employeeId) !== -1
? { productServiceId: item.id } ? { 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; return payment;
}); });

View file

@ -168,13 +168,21 @@ const permissionCond = createPermCondition(globalAllow);
export class QuotationController extends Controller { export class QuotationController extends Controller {
@Get("stats") @Get("stats")
@Security("keycloak") @Security("keycloak")
async getProductStats(@Request() req: RequestWithUser) { async getQuotationStats(
@Request() req: RequestWithUser,
@Query() startDate?: Date,
@Query() endDate?: Date,
) {
const result = await prisma.quotation.groupBy({ const result = await prisma.quotation.groupBy({
_count: true, _count: true,
by: "quotationStatus", by: "quotationStatus",
where: { where: {
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
isDebitNote: false, isDebitNote: false,
createdAt: {
gte: startDate,
lte: endDate,
},
}, },
}); });
@ -454,7 +462,7 @@ export class QuotationController extends Controller {
const { productServiceList: _productServiceList, worker: _worker, ...rest } = body; 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 nonExistEmployee = body.worker.filter((v) => typeof v !== "string");
const lastEmployee = await tx.runningNo.upsert({ const lastEmployee = await tx.runningNo.upsert({
where: { 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}") @Put("{quotationId}")
@ -1125,41 +1144,53 @@ export class QuotationActionController extends Controller {
}, },
update: { value: { increment: quotation.worker.length } }, update: { value: { increment: quotation.worker.length } },
}); });
await tx.quotation.update({ await tx.quotation
where: { id: quotationId, isDebitNote: false }, .update({
data: { include: { requestData: true },
quotationStatus: QuotationStatus.PaymentSuccess, // NOTE: change back if already complete or canceled where: { id: quotationId, isDebitNote: false },
worker: { data: {
createMany: { quotationStatus: QuotationStatus.PaymentSuccess, // NOTE: change back if already complete or canceled
data: rearrange worker: {
.filter((lhs) => !quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId)) createMany: {
.map((v, i) => ({ data: rearrange
no: quotation._count.worker + i + 1, .filter((lhs) => !quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId))
employeeId: v.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" || .then(async (ret) => {
quotation.quotationStatus === "PaymentSuccess" await prisma.notification.create({
? { data: {
create: rearrange title: "รายการคำขอใหม่ / New Request",
.filter( detail: "รหัส / code : " + ret.requestData.map((v) => v.code).join(", "),
(lhs) => registeredBranchId: ret.registeredBranchId,
!quotation.worker.find((rhs) => rhs.employeeId === lhs.workerId) && groupReceiver: { create: { name: "document_checker" } },
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,
},
});
}); });
} }
} }

View file

@ -30,6 +30,8 @@ import {
import { queryOrNot } from "../utils/relation"; import { queryOrNot } from "../utils/relation";
import { notFoundError } from "../utils/error"; import { notFoundError } from "../utils/error";
import { deleteFile, fileLocation, getFile, getPresigned, listFile, setFile } from "../utils/minio"; 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. // User in company can edit.
const permissionCheck = createPermCheck((_) => true); const permissionCheck = createPermCheck((_) => true);
@ -268,14 +270,24 @@ export class RequestDataActionController extends Controller {
}), }),
]); ]);
await Promise.all([ await Promise.all([
tx.quotation.updateMany({ tx.quotation
where: { .updateManyAndReturn({
requestData: { where: {
every: { requestDataStatus: RequestDataStatus.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({ tx.taskOrder.updateMany({
where: { where: {
taskList: { taskList: {
@ -405,14 +417,25 @@ export class RequestDataActionController extends Controller {
data: { taskStatus: TaskStatus.Canceled }, data: { taskStatus: TaskStatus.Canceled },
}); });
await Promise.all([ await Promise.all([
tx.quotation.updateMany({ tx.quotation
where: { .updateManyAndReturn({
requestData: { where: {
every: { requestDataStatus: RequestDataStatus.Canceled }, 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({ tx.taskOrder.updateMany({
where: { where: {
taskList: { taskList: {
@ -479,21 +502,31 @@ export class RequestDataActionController extends Controller {
where: { id: { in: completed } }, where: { id: { in: completed } },
data: { requestDataStatus: RequestDataStatus.Completed }, data: { requestDataStatus: RequestDataStatus.Completed },
}); });
await tx.quotation.updateMany({ await tx.quotation
where: { .updateManyAndReturn({
quotationStatus: { where: {
notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete], quotationStatus: {
}, notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete],
requestData: { },
every: { requestData: {
requestDataStatus: { every: {
in: [RequestDataStatus.Canceled, RequestDataStatus.Completed], 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); // dataRecord.push(record);
return data; return data;
}); });
@ -503,6 +536,14 @@ export class RequestDataActionController extends Controller {
@Route("/api/v1/request-work") @Route("/api/v1/request-work")
@Tags("Request List") @Tags("Request List")
export class RequestListController extends Controller { 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() @Get()
@Security("keycloak") @Security("keycloak")
async getRequestWork( async getRequestWork(
@ -812,14 +853,25 @@ export class RequestListController extends Controller {
data: { taskStatus: TaskStatus.Canceled }, data: { taskStatus: TaskStatus.Canceled },
}); });
await Promise.all([ await Promise.all([
tx.quotation.updateMany({ tx.quotation
where: { .updateManyAndReturn({
requestData: { where: {
every: { requestDataStatus: RequestDataStatus.Canceled }, 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({ tx.taskOrder.updateMany({
where: { where: {
taskList: { taskList: {
@ -887,19 +939,94 @@ export class RequestListController extends Controller {
where: { id: { in: completed } }, where: { id: { in: completed } },
data: { requestDataStatus: RequestDataStatus.Completed }, data: { requestDataStatus: RequestDataStatus.Completed },
}); });
await tx.quotation.updateMany({ await tx.quotation
where: { .updateManyAndReturn({
quotationStatus: { where: {
notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete], quotationStatus: {
}, notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete],
requestData: { },
every: { requestData: {
requestDataStatus: { in: [RequestDataStatus.Canceled, RequestDataStatus.Completed] }, 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; return record;
}); });
} }

View file

@ -433,88 +433,101 @@ export class TaskController extends Controller {
); );
} }
return await prisma.$transaction(async (tx) => { return await prisma
await Promise.all( .$transaction(async (tx) => {
record.taskList await Promise.all(
.filter( record.taskList
(lhs) => .filter(
!body.taskList.find( (lhs) =>
(rhs) => lhs.requestWorkId === rhs.requestWorkId && lhs.step === rhs.step, !body.taskList.find(
), (rhs) => lhs.requestWorkId === rhs.requestWorkId && lhs.step === rhs.step,
) ),
.map((v) => )
tx.task.update({ .map((v) =>
where: { id: v.id }, tx.task.update({
data: { where: { id: v.id },
requestWorkStep: { update: { workStatus: "Ready" } }, data: {
}, requestWorkStep: { update: { workStatus: "Ready" } },
}), },
), }),
); ),
);
await tx.requestWorkStepStatus.updateMany({ await tx.requestWorkStepStatus.updateMany({
where: { where: {
OR: body.taskList, OR: body.taskList,
workStatus: RequestWorkStatus.Ready, workStatus: RequestWorkStatus.Ready,
},
data: { workStatus: RequestWorkStatus.InProgress },
});
const work = await tx.requestWorkStepStatus.findMany({
include: {
requestWork: {
include: {
request: {
include: { quotation: true },
},
},
}, },
}, data: { workStatus: RequestWorkStatus.InProgress },
where: { OR: body.taskList }, });
});
return await tx.taskOrder.update({ const work = await tx.requestWorkStepStatus.findMany({
where: { id: taskOrderId }, include: {
include: { requestWork: {
taskList: { include: {
include: { request: {
requestWorkStep: { include: { quotation: true },
include: {
requestWork: true,
}, },
}, },
}, },
}, },
institution: true, where: { OR: body.taskList },
registeredBranch: true, });
createdBy: true,
}, return await tx.taskOrder.update({
data: { where: { id: taskOrderId },
...body, include: {
urgent: work.some((v) => v.requestWork.request.quotation.urgent), taskList: {
taskList: { include: {
deleteMany: record?.taskList requestWorkStep: {
.filter( include: {
(lhs) => requestWork: true,
!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,
}, },
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}") @Delete("{taskOrderId}")
@ -560,6 +573,14 @@ export class TaskController extends Controller {
@Route("/api/v1/task-order/{taskOrderId}") @Route("/api/v1/task-order/{taskOrderId}")
@Tags("Task Order") @Tags("Task Order")
export class TaskActionController extends Controller { 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") @Post("set-task-status")
@Security("keycloak") @Security("keycloak")
async changeTaskOrderTaskListStatus( async changeTaskOrderTaskListStatus(
@ -651,6 +672,13 @@ export class TaskActionController extends Controller {
}, },
data: { userTaskStatus: UserTaskStatus.Submit, submittedAt: new Date() }, 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 } }, where: { id: { in: completed } },
data: { requestDataStatus: RequestDataStatus.Completed }, data: { requestDataStatus: RequestDataStatus.Completed },
}); });
await tx.quotation.updateMany({ await tx.quotation
where: { .updateManyAndReturn({
quotationStatus: { where: {
notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete], quotationStatus: {
}, notIn: [QuotationStatus.Canceled, QuotationStatus.ProcessComplete],
requestData: { },
every: { requestData: {
requestDataStatus: { in: [RequestDataStatus.Canceled, RequestDataStatus.Completed] }, 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) => { await prisma.$transaction(async (tx) => {
const promises = body.taskOrderId.flatMap((taskOrderId) => [ const promises = body.taskOrderId.flatMap((taskOrderId) => [
tx.taskOrder.update({ tx.taskOrder
where: { id: taskOrderId }, .update({
data: { where: { id: taskOrderId },
taskOrderStatus: TaskOrderStatus.InProgress, data: {
userTask: { taskOrderStatus: TaskOrderStatus.InProgress,
deleteMany: { userId: req.user.sub }, userTask: {
create: { deleteMany: { userId: req.user.sub },
userId: req.user.sub, create: {
userTaskStatus: UserTaskStatus.Accept, userId: req.user.sub,
acceptedAt: new Date(), 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({ tx.task.updateMany({
where: { where: {
taskOrderId: taskOrderId, taskOrderId: taskOrderId,

View file

@ -1,9 +1,11 @@
import { import {
Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Head, Head,
Path, Path,
Post,
Put, Put,
Query, Query,
Request, Request,
@ -776,6 +778,70 @@ export class LineController extends Controller {
return record; 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}") @Route("api/v1/line/customer-branch/{branchId}")

View file

@ -68,37 +68,37 @@ export class WebHookController extends Controller {
const userIdLine = payload.events[0]?.source?.userId; const userIdLine = payload.events[0]?.source?.userId;
const dataNow = dayjs().tz("Asia/Bangkok").startOf("day"); const dataNow = dayjs().tz("Asia/Bangkok").startOf("day");
// const dataUser = await prisma.customerBranch.findFirst({ if (payload?.events[0]?.message) {
// where:{ const message = payload.events[0].message.text;
// userId:userIdLine
// }
// })
const dataEmployee = await prisma.employeePassport.findMany({ if (message === "เมนูหลัก > ข้อความ") {
select: { const dataEmployee = await prisma.employeePassport.findMany({
firstName: true,
firstNameEN: true,
lastName: true,
lastNameEN: true,
employeeId: true,
expireDate: true,
employee: {
select: { select: {
firstName: true, firstName: true,
firstNameEN: true,
lastName: true, lastName: true,
customerBranch: { lastNameEN: true,
employeeId: true,
expireDate: true,
employee: {
select: { select: {
firstName: true, firstName: true,
firstNameEN: true,
lastName: true, lastName: true,
lastNameEN: true, customerBranch: {
customerName: true,
customer: {
select: { select: {
customerType: true, firstName: true,
registeredBranch: { firstNameEN: true,
lastName: true,
lastNameEN: true,
customerName: true,
customer: {
select: { select: {
telephoneNo: true, customerType: true,
registeredBranch: {
select: {
telephoneNo: true,
},
},
}, },
}, },
}, },
@ -106,22 +106,28 @@ export class WebHookController extends Controller {
}, },
}, },
}, },
}, where: {
}, employee: {
where: { customerBranch: {
expireDate: { OR: [
lt: dataNow.add(30, "day").toDate(), { userId: userIdLine },
}, {
}, customer: {
orderBy: { branch: { some: { userId: userIdLine } },
expireDate: "asc", },
}, },
}); ],
},
},
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 dataUser = userIdLine;
const textHead = "JWS ALERT:"; const textHead = "JWS ALERT:";
let textData = ""; let textData = "";
@ -147,7 +153,7 @@ export class WebHookController extends Controller {
dayjs(item.expireDate).format("DD/MM/") + (dayjs(item.expireDate).year() + 543); dayjs(item.expireDate).format("DD/MM/") + (dayjs(item.expireDate).year() + 543);
const diffDate = dayjs(item.expireDate).diff(dayjs(), "day"); 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"); .join("\n");

View file

@ -1,3 +1,4 @@
import dayjs from "dayjs";
import { CronJob } from "cron"; import { CronJob } from "cron";
import prisma from "../db"; import prisma from "../db";
@ -25,6 +26,18 @@ const jobs = [
.catch((e) => console.error("[ERR]: Update expired quotation status, FAILED.", e)); .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() { export function initSchedule() {

View file

@ -53,7 +53,9 @@
{ "name": "Task Order" }, { "name": "Task Order" },
{ "name": "User Task Order" }, { "name": "User Task Order" },
{ "name": "Credit Note" }, { "name": "Credit Note" },
{ "name": "Debit Note" } { "name": "Debit Note" },
{ "name": "Report" },
{ "name": "Document Template" }
] ]
} }
}, },