feat: debit note (#9)

* fix: filter not work

* feat: add debit note flag to quotation

* feat: add debit note structure

* refactor: change name to debit

* refactor(quotation): only query quotation not debit note

* feat: delete debit note

* feat: get debit note by id

* chore: add import

* feat: debit note stats

* feat: get debit note list

* chore: add comment

* refactor: add debit note filter to invoice

* chore: migration

* refactor: change attachment endpoint to explicit declare

* add createDebitNote

* feat: add quotation relation to get endpoint

* fix: wrong query

* fix data to create

* feat: include debit note in relation

* feat: handle delete file on delete data

* feat: check if quotation exists

* feat: add update payload

* refactor: merge variable

* feat: add update endpoint debit note

* fix: quotation is not flagged as debit note

* feat: add worker into debit note

* feat: add update debit note with worker

* fix: missing remark field

* feat: auto invoice

This commit automatically create debit note invoice and payment data.
Debit note does not required to create invoice and do not have
installments.

* feat: set default get invoice param to only quotation

* refactor: debit note param in payment/invoice

* fixup! refactor: debit note param in payment/invoice

* fix: product does not have any worker

---------

Co-authored-by: Methapon2001 <61303214+Methapon2001@users.noreply.github.com>
Co-authored-by: Kanjana <taii.kanjana@gmail.com>
This commit is contained in:
Methapon Metanipat 2025-01-21 10:51:30 +07:00 committed by GitHub
parent 5fe6ce1d5c
commit 67651eb213
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 951 additions and 23 deletions

View file

@ -0,0 +1,18 @@
/*
Warnings:
- You are about to drop the `DebitNote` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "DebitNote" DROP CONSTRAINT "DebitNote_quotationId_fkey";
-- AlterTable
ALTER TABLE "Quotation" ADD COLUMN "debitNoteQuotationId" TEXT,
ADD COLUMN "isDebitNote" BOOLEAN NOT NULL DEFAULT false;
-- DropTable
DROP TABLE "DebitNote";
-- AddForeignKey
ALTER TABLE "Quotation" ADD CONSTRAINT "Quotation_debitNoteQuotationId_fkey" FOREIGN KEY ("debitNoteQuotationId") REFERENCES "Quotation"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1277,6 +1277,11 @@ model Quotation {
discount Float @default(0)
finalPrice Float
isDebitNote Boolean @default(false)
debitNoteQuotationId String?
debitNoteQuotation Quotation? @relation(name: "QuotationDebitNote", fields: [debitNoteQuotationId], references: [id])
debitNote Quotation[] @relation(name: "QuotationDebitNote")
requestData RequestData[]
createdAt DateTime @default(now())
@ -1288,7 +1293,6 @@ model Quotation {
invoice Invoice[]
creditNote CreditNote[]
debitNote DebitNote[]
}
model QuotationPaySplit {
@ -1625,12 +1629,3 @@ model CreditNote {
createdBy User? @relation(name: "CreditNoteCreatedByUser", fields: [createdByUserId], references: [id])
createdByUserId String?
}
model DebitNote {
id String @id @default(cuid())
quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade)
quotationId String
// NOTE: create quotation but with flag debit note?
}

View file

@ -44,15 +44,22 @@ export class InvoiceController extends Controller {
@Get("stats")
@OperationId("getInvoiceStats")
@Security("keycloak")
async getInvoiceStats(@Request() req: RequestWithUser, @Query() quotationId?: string) {
async getInvoiceStats(
@Request() req: RequestWithUser,
@Query() quotationOnly: boolean = true,
@Query() quotationId?: string,
@Query() debitNoteId?: string,
@Query() debitNoteOnly?: boolean,
) {
const where = {
quotationId,
quotation: {
id: quotationId,
isDebitNote: debitNoteId || debitNoteOnly ? true : quotationOnly ? false : undefined,
registeredBranch: {
OR: permissionCondCompany(req.user),
},
},
};
} satisfies Prisma.InvoiceWhereInput;
const [pay, notPay] = await prisma.$transaction([
prisma.invoice.count({
@ -83,7 +90,10 @@ export class InvoiceController extends Controller {
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() query: string = "",
@Query() quotationOnly: boolean = true,
@Query() debitNoteOnly?: boolean,
@Query() quotationId?: string,
@Query() debitNoteId?: string,
@Query() pay?: boolean,
) {
const where: Prisma.InvoiceWhereInput = {
@ -115,8 +125,9 @@ export class InvoiceController extends Controller {
: { not: PaymentStatus.PaymentSuccess },
}
: undefined,
quotationId,
quotation: {
id: quotationId || debitNoteId,
isDebitNote: debitNoteId || debitNoteOnly ? true : quotationOnly ? false : undefined,
registeredBranch: {
OR: permissionCondCompany(req.user),
},

View file

@ -17,13 +17,17 @@ export class ReceiptController extends Controller {
@Request() req: RequestWithUser,
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() quotationOnly: boolean = true,
@Query() quotationId?: string,
@Query() debitNoteId?: string,
@Query() debitNoteOnly?: boolean,
) {
const where: Prisma.PaymentWhereInput = {
paymentStatus: "PaymentSuccess",
invoice: {
quotationId,
quotation: {
id: quotationId || debitNoteId,
isDebitNote: debitNoteId || debitNoteOnly ? true : quotationOnly ? false : undefined,
registeredBranch: {
OR: permissionCondCompany(req.user),
},

View file

@ -45,12 +45,16 @@ export class QuotationPayment extends Controller {
@Request() req: RequestWithUser,
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() quotationOnly: boolean = true,
@Query() quotationId?: string,
@Query() debitNoteId?: string,
@Query() debitNoteOnly?: boolean,
) {
const where: Prisma.PaymentWhereInput = {
invoice: {
quotationId,
quotation: {
id: quotationId || debitNoteId,
isDebitNote: debitNoteId || debitNoteOnly ? true : quotationOnly ? false : undefined,
registeredBranch: {
OR: permissionCondCompany(req.user),
},

View file

@ -174,6 +174,7 @@ export class QuotationController extends Controller {
by: "quotationStatus",
where: {
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
isDebitNote: false,
},
});
@ -213,6 +214,7 @@ export class QuotationController extends Controller {
},
},
]),
isDebitNote: false,
code,
payCondition,
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
@ -332,6 +334,7 @@ export class QuotationController extends Controller {
},
},
},
debitNote: true,
productServiceList: {
include: {
service: {
@ -363,7 +366,7 @@ export class QuotationController extends Controller {
createdBy: true,
updatedBy: true,
},
where: { id: quotationId },
where: { id: quotationId, isDebitNote: false },
});
if (!record) throw notFoundError("Quotation");
@ -627,7 +630,7 @@ export class QuotationController extends Controller {
},
},
},
where: { id: quotationId },
where: { id: quotationId, isDebitNote: false },
});
if (!record) throw notFoundError("Quotation");
@ -837,7 +840,7 @@ export class QuotationController extends Controller {
select: { productServiceList: true },
},
},
where: { id: quotationId },
where: { id: quotationId, isDebitNote: false },
data: {
...rest,
...price,
@ -886,7 +889,7 @@ export class QuotationController extends Controller {
include: {
registeredBranch: { include: branchRelationPermInclude(req.user) },
},
where: { id: quotationId },
where: { id: quotationId, isDebitNote: false },
});
if (!record) throw notFoundError("Quotation");
@ -937,7 +940,7 @@ export class QuotationActionController extends Controller {
},
},
},
where: { id: quotationId },
where: { id: quotationId, isDebitNote: false },
}),
tx.employee.findMany({
where: { id: { in: ids.employee } },
@ -995,7 +998,7 @@ export class QuotationActionController extends Controller {
update: { value: { increment: quotation.worker.length } },
});
await tx.quotation.update({
where: { id: quotationId },
where: { id: quotationId, isDebitNote: false },
data: {
quotationStatus: QuotationStatus.PaymentSuccess, // NOTE: change back if already complete or canceled
worker: {
@ -1043,7 +1046,7 @@ export class QuotationFileController extends Controller {
include: branchRelationPermInclude(user),
},
},
where: { id },
where: { id, isDebitNote: false },
});
if (!data) throw notFoundError("Quotation");
await permissionCheck(user, data.registeredBranch);

View file

@ -0,0 +1,893 @@
import {
Body,
Controller,
Delete,
Get,
Head,
Path,
Post,
Put,
Query,
Request,
Route,
Security,
Tags,
} from "tsoa";
import { PayCondition, Prisma, QuotationStatus, Status } from "@prisma/client";
import prisma from "../db";
import config from "../config.json";
import { RequestWithUser } from "../interfaces/user";
import {
branchRelationPermInclude,
createPermCheck,
createPermCondition,
} from "../services/permission";
import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status";
import {
deleteFile,
deleteFolder,
fileLocation,
getFile,
getPresigned,
listFile,
setFile,
} from "../utils/minio";
import { isUsedError, notFoundError, relationError } from "../utils/error";
import { queryOrNot } from "../utils/relation";
import { isSystem } from "../utils/keycloak";
import { precisionRound } from "../utils/arithmetic";
const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"head_of_accountant",
"accountant",
"head_of_sale",
"sale",
];
function globalAllow(user: RequestWithUser["user"]) {
const allowList = ["system", "head_of_admin", "head_of_accountant", "head_of_sale"];
return allowList.some((v) => user.roles?.includes(v));
}
// NOTE: permission condition/check in registeredBranch
const permissionCond = createPermCondition(globalAllow);
const permissionCondCompany = createPermCondition((_) => true);
const permissionCheck = createPermCheck(globalAllow);
const permissionCheckCompany = createPermCheck((_) => true);
type DebitNoteCreate = {
quotationId: string;
agentPrice?: boolean;
discount?: number;
status?: Status;
payCondition: PayCondition;
dueDate: Date;
remark?: string | null;
worker: (
| string
| {
dateOfBirth: Date;
gender: string;
nationality: string;
namePrefix?: string;
firstName: string;
firstNameEN: string;
middleName?: string;
middleNameEN?: string;
lastName: string;
lastNameEN: string;
}
)[];
productServiceList: {
serviceId?: string;
workId?: string;
productId: string;
amount: number;
discount?: number;
installmentNo?: number;
workerIndex?: number[];
}[];
};
type DebitNoteUpdate = {
agentPrice?: boolean;
discount?: number;
status?: Status;
payCondition: PayCondition;
dueDate: Date;
remark?: string | null;
worker: (
| string
| {
dateOfBirth: Date;
gender: string;
nationality: string;
namePrefix?: string;
firstName: string;
firstNameEN: string;
middleName?: string;
middleNameEN?: string;
lastName: string;
lastNameEN: string;
}
)[];
productServiceList: {
serviceId?: string;
workId?: string;
productId: string;
amount: number;
discount?: number;
installmentNo?: number;
workerIndex?: number[];
}[];
};
const VAT_DEFAULT = config.vat;
@Route("api/v1/debit-note")
@Tags("Debit Note")
export class DebitNoteController extends Controller {
@Get("stats")
@Security("keycloak")
async getDebitNoteStats(@Request() req: RequestWithUser, @Query() quotationId?: string) {
const result = await prisma.quotation.groupBy({
_count: true,
by: "quotationStatus",
where: {
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
debitNoteQuotationId: quotationId,
isDebitNote: true,
},
});
return result.reduce<Record<string, number>>((a, c) => {
a[c.quotationStatus.charAt(0).toLowerCase() + c.quotationStatus.slice(1)] = c._count;
return a;
}, {});
}
@Get()
@Security("keycloak")
async getDebitNoteList(
@Request() req: RequestWithUser,
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() query: string = "",
@Query() quotationId?: string,
@Query() status?: QuotationStatus,
@Query() payCondition?: PayCondition,
@Query() includeRegisteredBranch?: boolean,
@Query() code?: string,
) {
return await this.getDebitNoteListByCriteria(
req,
page,
pageSize,
query,
quotationId,
status,
payCondition,
includeRegisteredBranch,
code,
);
}
// NOTE: only when needed or else remove this and implement in getCreditNoteList
@Post("list")
@Security("keycloak")
async getDebitNoteListByCriteria(
@Request() req: RequestWithUser,
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() query: string = "",
@Query() quotationId?: string,
@Query() status?: QuotationStatus,
@Query() payCondition?: PayCondition,
@Query() includeRegisteredBranch?: boolean,
@Query() code?: string,
@Body() body?: {},
) {
const where = {
OR: queryOrNot<Prisma.QuotationWhereInput[]>(query, [
{ code: { contains: query, mode: "insensitive" } },
{ workName: { contains: query } },
{
customerBranch: {
OR: [
{ code: { contains: query, mode: "insensitive" } },
{ customerName: { contains: query } },
{ firstName: { contains: query } },
{ firstNameEN: { contains: query } },
{ lastName: { contains: query } },
{ lastNameEN: { contains: query } },
],
},
},
]),
isDebitNote: true,
code,
payCondition,
debitNoteQuotationId: quotationId,
registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) },
quotationStatus: status,
} satisfies Prisma.QuotationWhereInput;
const [result, total] = await prisma.$transaction([
prisma.quotation.findMany({
where,
include: {
_count: {
select: { worker: true },
},
registeredBranch: includeRegisteredBranch,
debitNoteQuotation: true,
customerBranch: {
include: {
customer: {
include: { registeredBranch: true },
},
},
},
invoice: {
include: { payment: true },
},
createdBy: true,
updatedBy: true,
},
orderBy: { createdAt: "desc" },
take: pageSize,
skip: (page - 1) * pageSize,
}),
prisma.quotation.count({ where }),
]);
return { result: result, page, pageSize, total };
}
@Get("{debitNoteId}")
@Security("keycloak")
async getDebitNote(@Request() req: RequestWithUser, @Path() debitNoteId: string) {
const record = await prisma.quotation.findUnique({
include: {
_count: {
select: { worker: true },
},
registeredBranch: true,
customerBranch: {
include: {
province: true,
district: true,
subDistrict: true,
},
},
debitNoteQuotation: true,
worker: {
include: {
employee: {
include: {
employeePassport: {
orderBy: { expireDate: "desc" },
},
},
},
},
},
productServiceList: {
include: {
service: {
include: {
productGroup: true,
work: {
include: {
productOnWork: {
include: {
product: true,
},
},
},
},
},
},
work: true,
product: {
include: { productGroup: true },
},
worker: true,
},
},
invoice: {
include: {
payment: true,
},
},
createdBy: true,
updatedBy: true,
},
where: { id: debitNoteId, isDebitNote: true },
});
if (!record) throw notFoundError("Debit Note");
return record;
}
@Post()
@Security("keycloak", MANAGE_ROLES)
async createDebitNote(@Request() req: RequestWithUser, @Body() body: DebitNoteCreate) {
// NOTE:
// - when create debit note quotation must be added to debitNoteQuotation relation
// - when create debit note customer must be pulled from original quotation ลูกค้าจะต้องดึงจากใบเสนอราคาเดิม
// - when create debit note quotation status must be at least after payment was performed
const { productServiceList: _productServiceList, quotationId, ...rest } = body;
const ids = {
employee: body.worker.filter((v) => typeof v === "string"),
product: body.productServiceList
.map((v) => v.productId)
.filter((v, i, a) => a.findIndex((c) => c === v) === i),
work: body.productServiceList
.map((v) => v.workId || [])
.filter((v, i, a) => a.findIndex((c) => c === v) === i)
.flat(),
service: body.productServiceList
.map((v) => v.serviceId || [])
.filter((v, i, a) => a.findIndex((c) => c === v) === i)
.flat(),
};
const [employee, product, work, service] = await prisma.$transaction(
async (tx) =>
await Promise.all([
tx.employee.findMany({ where: { id: { in: ids.employee } } }),
tx.product.findMany({ where: { id: { in: ids.product } } }),
ids.work.length ? tx.work.findMany({ where: { id: { in: ids.work } } }) : null,
ids.service.length ? tx.service.findMany({ where: { id: { in: ids.service } } }) : null,
]),
);
if (ids.employee.length !== employee.length) throw relationError("Worker");
if (ids.product.length !== product.length) throw relationError("Product");
if (ids.work.length && ids.work.length !== work?.length) throw relationError("Work");
if (ids.service.length && ids.service.length !== service?.length) {
throw relationError("Service");
}
return await prisma.$transaction(async (tx) => {
const master = await tx.quotation.findFirst({
include: {
customerBranch: true,
},
where: {
id: body.quotationId,
isDebitNote: false,
},
});
if (!master) throw notFoundError("Quotation");
const customerBranch = master.customerBranch;
const nonExistEmployee = body.worker.filter((v) => typeof v !== "string");
const lastEmployee = await tx.runningNo.upsert({
where: {
key: `EMPLOYEE_${customerBranch.id}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
},
create: {
key: `EMPLOYEE_${customerBranch.id}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
value: nonExistEmployee.length,
},
update: { value: { increment: nonExistEmployee.length } },
});
const newEmployee = await Promise.all(
nonExistEmployee.map((v, i) =>
tx.employee.create({
data: {
...v,
code: `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`,
customerBranchId: customerBranch.id,
},
}),
),
);
const sortedEmployeeId: string[] = [];
while (body.worker.length > 0) {
const popExist = body.worker.shift();
if (typeof popExist === "string") sortedEmployeeId.push(popExist);
else {
const popNew = newEmployee.shift();
popNew && sortedEmployeeId.push(popNew.id);
}
}
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const lastQuotation = await tx.runningNo.upsert({
where: {
key: `DEิBITNOTE_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}`,
},
create: {
key: `DEิBITNOTE_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}`,
value: 1,
},
update: { value: { increment: 1 } },
});
const list = body.productServiceList.map((v, i) => {
const p = product.find((p) => p.id === v.productId)!;
const price = body.agentPrice ? p.agentPrice : p.price;
const pricePerUnit = p.vatIncluded ? price / (1 + VAT_DEFAULT) : price;
const vat = p.calcVat
? (pricePerUnit * (v.discount ? v.amount : 1) - (v.discount || 0)) *
VAT_DEFAULT *
(!v.discount ? v.amount : 1)
: 0;
return {
order: i + 1,
productId: v.productId,
workId: v.workId,
serviceId: v.serviceId,
pricePerUnit,
amount: v.amount,
discount: v.discount || 0,
installmentNo: v.installmentNo,
vat,
worker: {
create: sortedEmployeeId
.filter((_, i) => !v.workerIndex || i in v.workerIndex)
.map((employeeId) => ({ employeeId })),
},
};
});
const price = list.reduce(
(a, c) => {
a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount);
a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
a.vat = precisionRound(a.vat + c.vat);
a.vatExcluded =
c.vat === 0
? precisionRound(
a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT,
)
: a.vatExcluded;
a.finalPrice = precisionRound(
Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0),
);
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
discount: body.discount,
finalPrice: 0,
},
);
await Promise.all([
tx.service.updateMany({
where: { id: { in: ids.service }, status: Status.CREATED },
data: { status: Status.ACTIVE },
}),
tx.product.updateMany({
where: { id: { in: ids.product }, status: Status.CREATED },
data: { status: Status.ACTIVE },
}),
]);
return await tx.quotation.create({
include: {
productServiceList: {
include: {
service: {
include: {
productGroup: true,
work: {
include: {
productOnWork: {
include: {
product: true,
},
},
},
},
},
},
work: true,
product: {
include: { productGroup: true },
},
worker: true,
},
},
worker: true,
invoice: {
include: {
payment: true,
},
},
customerBranch: {
include: { customer: true },
},
_count: {
select: { productServiceList: true },
},
},
data: {
...rest,
...price,
isDebitNote: true,
debitNoteQuotationId: quotationId,
quotationStatus: QuotationStatus.PaymentPending,
statusOrder: +(rest.status === "INACTIVE"),
code: `DN${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${lastQuotation.value.toString().padStart(6, "0")}`,
contactName: master?.contactName ?? "",
contactTel: master?.contactTel ?? "",
customerBranchId: master?.customerBranchId ?? "",
dueDate: body.dueDate,
payCondition: body.payCondition,
registeredBranchId: master?.registeredBranchId ?? "",
workName: master?.workName ?? "",
worker: {
createMany: {
data: sortedEmployeeId.map((v, i) => ({
no: i,
employeeId: v,
})),
},
},
productServiceList: {
create: list,
},
invoice: {
create: {
code: "",
amount: price.finalPrice,
payment: {
create: {
paymentStatus: "PaymentWait",
amount: price.finalPrice,
},
},
createdByUserId: req.user.sub,
},
},
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
});
}
@Put("{debitNoteId}")
@Security("keycloak", MANAGE_ROLES)
async updateDebitNote(
@Request() req: RequestWithUser,
@Path() debitNoteId: string,
@Body() body: DebitNoteUpdate,
) {
const record = await prisma.quotation.findUnique({
include: {
registeredBranch: { include: branchRelationPermInclude(req.user) },
customerBranch: {
include: {
customer: {
include: {
registeredBranch: { include: branchRelationPermInclude(req.user) },
},
},
},
},
},
where: { id: debitNoteId, isDebitNote: true },
});
if (!record) throw notFoundError("Debit Note");
await permissionCheckCompany(req.user, record.registeredBranch);
const { productServiceList: _productServiceList, ...rest } = body;
const ids = {
employee: body.worker.filter((v) => typeof v === "string"),
product: body.productServiceList
.map((v) => v.productId)
.filter((v, i, a) => a.findIndex((c) => c === v) === i),
work: body.productServiceList
.map((v) => v.workId || [])
.filter((v, i, a) => a.findIndex((c) => c === v) === i)
.flat(),
service: body.productServiceList
.map((v) => v.serviceId || [])
.filter((v, i, a) => a.findIndex((c) => c === v) === i)
.flat(),
};
const [employee, product, work, service] = await prisma.$transaction(
async (tx) =>
await Promise.all([
tx.employee.findMany({ where: { id: { in: ids.employee } } }),
tx.product.findMany({ where: { id: { in: ids.product } } }),
ids.work.length ? tx.work.findMany({ where: { id: { in: ids.work } } }) : null,
ids.service.length ? tx.service.findMany({ where: { id: { in: ids.service } } }) : null,
]),
);
if (ids.employee.length !== employee.length) throw relationError("Worker");
if (ids.product.length !== product.length) throw relationError("Product");
if (ids.work.length && ids.work.length !== work?.length) throw relationError("Work");
if (ids.service.length && ids.service.length !== service?.length) {
throw relationError("Service");
}
return await prisma.$transaction(async (tx) => {
const customerBranch = record.customerBranch;
const nonExistEmployee = body.worker.filter((v) => typeof v !== "string");
const lastEmployee = await tx.runningNo.upsert({
where: {
key: `EMPLOYEE_${customerBranch.id}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
},
create: {
key: `EMPLOYEE_${customerBranch.id}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
value: nonExistEmployee.length,
},
update: { value: { increment: nonExistEmployee.length } },
});
const newEmployee = await Promise.all(
nonExistEmployee.map((v, i) =>
tx.employee.create({
data: {
...v,
code: `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${lastEmployee.value - nonExistEmployee.length + i + 1}`.padStart(7, "0")}`,
customerBranchId: customerBranch.id,
},
}),
),
);
const sortedEmployeeId: string[] = [];
while (body.worker.length > 0) {
const popExist = body.worker.shift();
if (typeof popExist === "string") sortedEmployeeId.push(popExist);
else {
const popNew = newEmployee.shift();
popNew && sortedEmployeeId.push(popNew.id);
}
}
const list = body.productServiceList.map((v, i) => {
const p = product.find((p) => p.id === v.productId)!;
const price = body.agentPrice ? p.agentPrice : p.price;
const pricePerUnit = p.vatIncluded ? price / (1 + VAT_DEFAULT) : price;
const vat = p.calcVat
? (pricePerUnit * (v.discount ? v.amount : 1) - (v.discount || 0)) *
VAT_DEFAULT *
(!v.discount ? v.amount : 1)
: 0;
return {
order: i + 1,
productId: v.productId,
workId: v.workId,
serviceId: v.serviceId,
pricePerUnit,
amount: v.amount,
discount: v.discount || 0,
installmentNo: v.installmentNo,
vat,
worker: {
create: sortedEmployeeId
.filter((_, i) => !v.workerIndex || i in v.workerIndex)
.map((employeeId) => ({ employeeId })),
},
};
});
const price = list.reduce(
(a, c) => {
a.totalPrice = precisionRound(a.totalPrice + c.pricePerUnit * c.amount);
a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
a.vat = precisionRound(a.vat + c.vat);
a.vatExcluded =
c.vat === 0
? precisionRound(
a.vatExcluded + (c.pricePerUnit * c.amount - (c.discount || 0)) * VAT_DEFAULT,
)
: a.vatExcluded;
a.finalPrice = precisionRound(
Math.max(a.totalPrice - a.totalDiscount + a.vat - (body.discount || 0), 0),
);
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
discount: body.discount,
finalPrice: 0,
},
);
await Promise.all([
tx.service.updateMany({
where: { id: { in: ids.service }, status: Status.CREATED },
data: { status: Status.ACTIVE },
}),
tx.product.updateMany({
where: { id: { in: ids.product }, status: Status.CREATED },
data: { status: Status.ACTIVE },
}),
]);
return await tx.quotation.update({
include: {
productServiceList: {
include: {
service: {
include: {
productGroup: true,
work: {
include: {
productOnWork: {
include: {
product: true,
},
},
},
},
},
},
work: true,
product: {
include: { productGroup: true },
},
worker: true,
},
},
worker: true,
customerBranch: {
include: { customer: true },
},
_count: {
select: { productServiceList: true },
},
},
where: { id: debitNoteId, isDebitNote: true },
data: {
...rest,
...price,
statusOrder: +(rest.status === "INACTIVE"),
worker:
sortedEmployeeId.length > 0
? {
deleteMany: { id: { notIn: sortedEmployeeId } },
createMany: {
skipDuplicates: true,
data: sortedEmployeeId.map((v, i) => ({
no: i,
employeeId: v,
})),
},
}
: undefined,
productServiceList: list
? {
deleteMany: {},
create: list,
}
: undefined,
updatedByUserId: req.user.sub,
},
});
});
}
@Delete("{debitNoteId}")
@Security("keycloak", MANAGE_ROLES)
async deleteDebitNote(@Request() req: RequestWithUser, @Path() debitNoteId: string) {
const record = await prisma.quotation.findUnique({
include: {
registeredBranch: { include: branchRelationPermInclude(req.user) },
},
where: { id: debitNoteId, isDebitNote: true },
});
if (!record) throw notFoundError("Quotation");
await permissionCheck(req.user, record.registeredBranch);
if (record.status !== Status.CREATED) throw isUsedError("Debit Note");
await Promise.all([deleteFolder(fileLocation.quotation.attachment(debitNoteId))]);
return await prisma.quotation.delete({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: debitNoteId, isDebitNote: true },
});
}
}
@Route("api/v1/debit-note/{debitNoteId}")
@Tags("Debit Note")
export class DebitNoteFileController extends Controller {
async #checkPermission(user: RequestWithUser["user"], id: string) {
const data = await prisma.quotation.findUnique({
include: {
registeredBranch: {
include: branchRelationPermInclude(user),
},
},
where: { id, isDebitNote: true },
});
if (!data) throw notFoundError("Debit Note");
await permissionCheck(user, data.registeredBranch);
}
@Get("attachment")
@Security("keycloak")
async listAttachment(@Request() req: RequestWithUser, @Path() debitNoteId: string) {
await this.#checkPermission(req.user, debitNoteId);
return await listFile(fileLocation.quotation.attachment(debitNoteId));
}
@Head("attachment/{name}")
async headAttachment(
@Request() req: RequestWithUser,
@Path() debitNoteId: string,
@Path() name: string,
) {
return req.res?.redirect(
await getPresigned("head", fileLocation.quotation.attachment(debitNoteId, name)),
);
}
@Get("attachment/{name}")
@Security("keycloak")
async getAttachment(
@Request() req: RequestWithUser,
@Path() debitNoteId: string,
@Path() name: string,
) {
await this.#checkPermission(req.user, debitNoteId);
return await getFile(fileLocation.quotation.attachment(debitNoteId, name));
}
@Put("attachment/{name}")
@Security("keycloak", MANAGE_ROLES)
async putAttachment(
@Request() req: RequestWithUser,
@Path() debitNoteId: string,
@Path() name: string,
) {
await this.#checkPermission(req.user, debitNoteId);
return await setFile(fileLocation.quotation.attachment(debitNoteId, name));
}
@Delete("attachment/{name}")
@Security("keycloak", MANAGE_ROLES)
async deleteAttachment(
@Request() req: RequestWithUser,
@Path() debitNoteId: string,
@Path() name: string,
) {
await this.#checkPermission(req.user, debitNoteId);
return await deleteFile(fileLocation.quotation.attachment(debitNoteId, name));
}
}