jws-backend/src/controllers/00-stats-controller.ts

770 lines
22 KiB
TypeScript
Raw Normal View History

2025-03-04 15:19:44 +07:00
import config from "../config.json";
2025-03-04 13:42:10 +07:00
import {
Customer,
CustomerBranch,
ProductGroup,
QuotationStatus,
RequestWorkStatus,
2025-03-10 09:55:55 +07:00
PaymentStatus,
2025-03-04 13:42:10 +07:00
User,
2025-03-10 09:55:55 +07:00
Invoice,
CustomerType,
2025-03-04 13:42:10 +07:00
} from "@prisma/client";
2025-03-04 14:37:13 +07:00
import { Controller, Get, Query, Request, Route, Security, Tags } from "tsoa";
2025-03-04 11:27:15 +07:00
import prisma from "../db";
import { createPermCondition } from "../services/permission";
import { RequestWithUser } from "../interfaces/user";
2025-03-04 15:19:44 +07:00
import { precisionRound } from "../utils/arithmetic";
2025-03-04 18:12:07 +07:00
import dayjs from "dayjs";
2025-03-05 17:49:47 +07:00
import { json2csv } from "json-2-csv";
2025-03-10 09:55:55 +07:00
import { isSystem } from "../utils/keycloak";
2025-03-04 11:27:15 +07:00
const permissionCondCompany = createPermCondition((_) => true);
2025-03-04 15:19:44 +07:00
const VAT_DEFAULT = config.vat;
2025-03-04 11:27:15 +07:00
@Route("/api/v1/report")
@Security("keycloak")
2025-03-04 14:37:13 +07:00
@Tags("Report")
2025-03-04 11:27:15 +07:00
export class StatsController extends Controller {
2025-03-05 17:49:47 +07:00
@Get("quotation/download")
async downloadQuotationReport(
@Request() req: RequestWithUser,
@Query() limit?: number,
@Query() startDate?: Date,
@Query() endDate?: Date,
) {
this.setHeader("Content-Type", "text/csv");
2025-03-06 15:19:27 +07:00
return json2csv(
await this.quotationReport(req, limit, startDate, endDate).then((v) =>
v.map((v) => ({
...v,
customerBranch: {
...v.customerBranch,
customerType: v.customerBranch.customer.customerType,
customer: undefined,
},
})),
),
{ useDateIso8601Format: true },
);
2025-03-05 17:49:47 +07:00
}
2025-03-04 11:27:15 +07:00
@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,
2025-03-06 15:19:27 +07:00
customerBranch: {
2025-03-06 17:32:52 +07:00
include: { customer: true },
2025-03-06 15:19:27 +07:00
},
finalPrice: true,
2025-03-04 11:27:15 +07:00
createdAt: true,
updatedAt: true,
},
where: {
registeredBranch: { OR: permissionCondCompany(req.user) },
createdAt: { gte: startDate, lte: endDate },
},
2025-03-04 13:50:23 +07:00
orderBy: { createdAt: "desc" },
2025-03-04 11:27:15 +07:00
take: limit,
});
return record.map((v) => ({
document: "quotation",
code: v.code,
status: v.quotationStatus,
2025-03-06 15:19:27 +07:00
amount: v.finalPrice,
2025-03-04 11:27:15 +07:00
createdAt: v.createdAt,
updatedAt: v.updatedAt,
2025-03-06 15:19:27 +07:00
customerBranch: v.customerBranch,
2025-03-04 11:27:15 +07:00
}));
}
2025-03-05 17:49:47 +07:00
@Get("invoice/download")
async downloadInvoiceReport(
@Request() req: RequestWithUser,
@Query() limit?: number,
@Query() startDate?: Date,
@Query() endDate?: Date,
) {
this.setHeader("Content-Type", "text/csv");
2025-03-06 15:19:27 +07:00
return json2csv(
await this.invoiceReport(req, limit, startDate, endDate).then((v) =>
v.map((v) => ({
...v,
customerBranch: {
...v.customerBranch,
customerType: v.customerBranch.customer.customerType,
customer: undefined,
},
})),
),
{
useDateIso8601Format: true,
},
);
2025-03-05 17:49:47 +07:00
}
2025-03-04 11:27:15 +07:00
@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,
2025-03-06 15:19:27 +07:00
quotation: {
select: {
2025-03-06 17:32:52 +07:00
customerBranch: { include: { customer: true } },
2025-03-06 15:19:27 +07:00
},
},
2025-03-04 11:27:15 +07:00
payment: {
select: {
paymentStatus: true,
},
},
2025-03-05 14:57:09 +07:00
amount: true,
2025-03-04 11:27:15 +07:00
createdAt: true,
},
where: {
quotation: {
isDebitNote: false,
registeredBranch: { OR: permissionCondCompany(req.user) },
},
createdAt: { gte: startDate, lte: endDate },
},
2025-03-04 13:50:23 +07:00
orderBy: { createdAt: "desc" },
2025-03-04 11:27:15 +07:00
take: limit,
});
return record.map((v) => ({
document: "invoice",
code: v.code,
status: v.payment?.paymentStatus,
2025-03-05 14:57:09 +07:00
amount: v.amount,
2025-03-04 11:27:15 +07:00
createdAt: v.createdAt,
2025-03-06 15:19:27 +07:00
customerBranch: v.quotation.customerBranch,
2025-03-04 11:27:15 +07:00
}));
}
2025-03-05 17:49:47 +07:00
@Get("receipt/download")
async downloadReceiptReport(
@Request() req: RequestWithUser,
@Query() limit?: number,
@Query() startDate?: Date,
@Query() endDate?: Date,
) {
this.setHeader("Content-Type", "text/csv");
2025-03-06 15:19:27 +07:00
return json2csv(
await this.receiptReport(req, limit, startDate, endDate).then((v) =>
v.map((v) => ({
...v,
customerBranch: {
...v.customerBranch,
customerType: v.customerBranch.customer.customerType,
customer: undefined,
},
})),
),
{
useDateIso8601Format: true,
},
);
2025-03-05 17:49:47 +07:00
}
2025-03-04 11:27:15 +07:00
@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,
2025-03-06 15:19:27 +07:00
invoice: {
select: {
quotation: {
2025-03-06 17:32:52 +07:00
select: { customerBranch: { include: { customer: true } } },
2025-03-06 15:19:27 +07:00
},
},
},
amount: true,
2025-03-04 11:27:15 +07:00
paymentStatus: true,
createdAt: true,
},
where: {
paymentStatus: PaymentStatus.PaymentSuccess,
invoice: {
quotation: {
isDebitNote: false,
registeredBranch: { OR: permissionCondCompany(req.user) },
},
},
createdAt: { gte: startDate, lte: endDate },
},
2025-03-04 13:50:23 +07:00
orderBy: { createdAt: "desc" },
2025-03-04 11:27:15 +07:00
take: limit,
});
return record.map((v) => ({
document: "receipt",
code: v.code,
2025-03-06 15:19:27 +07:00
amount: v.amount,
2025-03-04 11:27:15 +07:00
status: v.paymentStatus,
createdAt: v.createdAt,
2025-03-06 15:19:27 +07:00
customerBranch: v.invoice.quotation.customerBranch,
2025-03-04 11:27:15 +07:00
}));
}
2025-03-06 10:43:04 +07:00
@Get("product/download")
2025-03-06 10:09:35 +07:00
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,
});
}
2025-03-04 11:27:15 +07:00
@Get("product")
async productReport(
@Request() req: RequestWithUser,
@Query() limit?: number,
@Query() startDate?: Date,
@Query() endDate?: Date,
) {
2025-03-05 15:04:17 +07:00
return await prisma.$transaction(async (tx) => {
2025-03-04 15:19:44 +07:00
const record = await tx.product.findMany({
select: {
id: true,
code: true,
name: true,
createdAt: true,
updatedAt: true,
quotationProductServiceList: {
include: { quotation: true },
},
2025-03-04 15:19:44 +07:00
_count: {
select: {
quotationProductServiceList: {
where: {
quotation: {
quotationStatus: {
in: [
QuotationStatus.PaymentInProcess,
QuotationStatus.PaymentSuccess,
QuotationStatus.ProcessComplete,
],
},
2025-03-04 11:27:15 +07:00
},
},
},
},
},
},
2025-03-04 15:19:44 +07:00
where: {
quotationProductServiceList: {
some: {
quotation: { createdAt: { gte: startDate, lte: endDate } },
},
2025-03-04 13:42:01 +07:00
},
2025-03-04 15:19:44 +07:00
productGroup: { registeredBranch: { OR: permissionCondCompany(req.user) } },
2025-03-04 13:42:01 +07:00
},
2025-03-04 15:19:44 +07:00
orderBy: {
quotationProductServiceList: { _count: "desc" },
},
2025-03-04 15:19:44 +07:00
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,
],
},
2025-03-04 11:27:15 +07:00
},
},
},
},
},
2025-03-04 15:19:44 +07:00
});
2025-03-04 11:27:15 +07:00
2025-03-04 15:19:44 +07:00
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) },
},
2025-03-04 15:19:44 +07:00
});
2025-03-04 15:19:44 +07:00
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,
}));
});
2025-03-04 11:27:15 +07:00
}
2025-03-04 11:46:11 +07:00
2025-03-06 11:42:49 +07:00
@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 },
);
}
2025-03-04 11:46:11 +07:00
@Get("sale")
async saleReport(
@Request() req: RequestWithUser,
@Query() limit?: number,
@Query() startDate?: Date,
@Query() endDate?: Date,
2025-03-04 13:42:10 +07:00
) {
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: [] },
);
}
2025-03-04 15:19:44 +07:00
@Get("profit")
async profit(
@Request() req: RequestWithUser,
@Query() startDate?: Date,
@Query() endDate?: Date,
) {
const record = await prisma.quotationProductServiceList.findMany({
include: {
2025-03-04 16:10:06 +07:00
work: {
include: {
productOnWork: {
select: { stepCount: true, productId: true },
},
},
},
2025-03-04 15:19:44 +07:00
product: {
select: {
agentPrice: true,
agentPriceCalcVat: true,
agentPriceVatIncluded: true,
serviceCharge: true,
serviceChargeCalcVat: true,
serviceChargeVatIncluded: true,
price: true,
calcVat: true,
vatIncluded: true,
},
},
2025-03-04 16:10:06 +07:00
requestWork: {
include: {
stepStatus: true,
creditNote: true,
},
},
2025-03-04 15:19:44 +07:00
quotation: {
select: {
agentPrice: true,
2025-03-04 16:10:06 +07:00
creditNote: true,
2025-03-06 16:22:09 +07:00
createdAt: true,
2025-03-04 15:19:44 +07:00
},
},
},
where: {
quotation: {
quotationStatus: {
in: [
QuotationStatus.PaymentInProcess,
QuotationStatus.PaymentSuccess,
QuotationStatus.ProcessComplete,
],
},
registeredBranch: {
OR: permissionCondCompany(req.user),
},
2025-03-04 16:34:15 +07:00
createdAt: { gte: startDate, lte: endDate },
2025-03-04 15:19:44 +07:00
},
},
});
2025-03-04 16:10:06 +07:00
const data = record.map((v) => {
2025-03-04 15:19:44 +07:00
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);
2025-03-04 16:10:06 +07:00
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 {
2025-03-06 16:22:09 +07:00
month: v.quotation.createdAt.getMonth() + 1,
year: v.quotation.createdAt.getFullYear(),
2025-03-04 16:10:06 +07:00
income,
expenses,
netProfit,
};
});
2025-03-04 15:19:44 +07:00
});
2025-03-06 16:22:09 +07:00
return data
.flat()
.reduce<{ income: number; expenses: 0; netProfit: 0; dataset: (typeof data)[number] }>(
(a, c) => {
const current = a.dataset.find((v) => v.month === c.month && v.year === c.year);
if (current) {
current.income += c.income;
current.expenses += c.expenses;
current.netProfit += c.netProfit;
2025-03-07 13:39:20 +07:00
} else {
a.dataset.push(c);
2025-03-06 16:22:09 +07:00
}
a.income += c.income;
a.expenses += c.expenses;
a.netProfit += c.netProfit;
return a;
},
{ income: 0, expenses: 0, netProfit: 0, dataset: [] },
);
2025-03-04 15:19:44 +07:00
}
2025-03-04 18:12:07 +07:00
@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 },
2025-03-05 08:49:27 +07:00
where: {
createdAt: { gte: v, lte: date.endOf("month").toDate() },
invoice: {
quotation: {
registeredBranch: { OR: permissionCondCompany(req.user) },
},
},
},
2025-03-04 18:12:07 +07:00
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;
}
2025-03-10 09:55:55 +07:00
@Get("customer-dept")
async reportCustomerDept(@Request() req: RequestWithUser) {
let query = prisma.$kysely
2025-03-10 10:25:01 +07:00
.selectFrom("Quotation")
.leftJoin("Invoice", "Quotation.id", "Invoice.quotationId")
2025-03-10 09:55:55 +07:00
.leftJoin("Payment", "Invoice.id", "Payment.invoiceId")
.leftJoin("CustomerBranch", "CustomerBranch.id", "Quotation.customerBranchId")
.leftJoin("Customer", "Customer.id", "CustomerBranch.customerId")
.select([
"CustomerBranch.id as customerBranchId",
"CustomerBranch.code as customerBranchCode",
"CustomerBranch.registerName as customerBranchRegisterName",
"CustomerBranch.registerNameEN as customerBranchRegisterNameEN",
"CustomerBranch.firstName as customerBranchFirstName",
"CustomerBranch.firstNameEN as customerBranchFirstNameEN",
"CustomerBranch.lastName as customerBranchFirstName",
"CustomerBranch.lastNameEN as customerBranchFirstNameEN",
"Customer.customerType",
"Quotation.id as quotationId",
2025-03-10 10:25:01 +07:00
"Quotation.code as quotationCode",
2025-03-10 09:55:55 +07:00
"Quotation.finalPrice as quotationValue",
])
.select(["Payment.paymentStatus"])
.selectAll(["Invoice"])
2025-03-10 10:25:01 +07:00
.distinctOn("Quotation.id");
2025-03-10 09:55:55 +07:00
if (!isSystem(req.user)) {
query = query.where(({ eb, exists }) =>
exists(
eb
.selectFrom("Branch")
.leftJoin("BranchUser", "BranchUser.branchId", "Branch.id")
.leftJoin("Branch as SubBranch", "SubBranch.headOfficeId", "Branch.id")
.leftJoin("BranchUser as SubBranchUser", "SubBranchUser.branchId", "SubBranch.id")
.leftJoin("Branch as HeadBranch", "HeadBranch.id", "Branch.id")
.leftJoin("BranchUser as HeadBranchUser", "HeadBranchUser.branchId", "HeadBranch.id")
.leftJoin("Branch as SubHeadBranch", "SubHeadBranch.headOfficeId", "HeadBranch.id")
.leftJoin(
"BranchUser as SubHeadBranchUser",
"SubHeadBranchUser.branchId",
"SubHeadBranch.id",
)
.where((eb) => {
const cond = [
eb("BranchUser.userId", "=", req.user.sub), // NOTE: if user belong to current branch.
eb("SubBranchUser.userId", "=", req.user.sub), // NOTE: if user belong to branch under current branch.
eb("HeadBranchUser.userId", "=", req.user.sub), // NOTE: if the current branch is under head branch user belong to.
eb("SubHeadBranchUser.userId", "=", req.user.sub), // NOTE: if the current branch is under the same head branch user belong to.
];
return eb.or(cond);
})
.select("Branch.id"),
),
);
}
const ret = await query.execute();
const arr = ret.map((item) => {
const data: Record<string, any> = {};
for (const [key, val] of Object.entries(item)) {
if (key.startsWith("customerBranch")) {
if (!data["customerBranch"]) data["customerBranch"] = {};
data["customerBranch"][key.slice(14).slice(0, 1).toLowerCase() + key.slice(14).slice(1)] =
val;
} else if (key.startsWith("customerType")) {
data["customerBranch"]["customer"] = { customerType: val };
} else {
data[key as keyof typeof data] = val;
}
}
return data as Invoice & {
quotationId: string;
2025-03-10 10:25:01 +07:00
quotationCode: string;
2025-03-10 09:55:55 +07:00
quotationValue: number;
paymentStatus: PaymentStatus;
customerBranch: CustomerBranch & { customer: { customerType: CustomerType } };
};
});
return arr
.reduce<
{
paid: number;
unpaid: number;
customerBranch: CustomerBranch & { customer: { customerType: CustomerType } };
2025-03-10 10:25:01 +07:00
_quotation: { id: string; code: string; value: number }[];
2025-03-10 09:55:55 +07:00
}[]
>((acc, item) => {
const exists = acc.find((v) => v.customerBranch.id === item.customerBranch.id);
const quotation = {
id: item.quotationId,
2025-03-10 10:25:01 +07:00
code: item.quotationCode,
2025-03-10 09:55:55 +07:00
value:
item.quotationValue -
(item.paymentStatus === "PaymentSuccess" && item.amount ? item.amount : 0),
};
if (!exists) {
return acc.concat({
_quotation: [quotation],
customerBranch: item.customerBranch,
paid: item.paymentStatus === "PaymentSuccess" && item.amount ? item.amount : 0,
unpaid: quotation.value,
});
2025-03-10 10:25:01 +07:00
}
2025-03-10 09:55:55 +07:00
2025-03-10 10:25:01 +07:00
const same = exists._quotation.find((v) => v.id === item.quotationId);
2025-03-10 09:55:55 +07:00
2025-03-10 10:25:01 +07:00
if (item.paymentStatus === "PaymentSuccess" && item.amount) {
exists.paid += item.amount;
if (same) same.value -= item.amount;
2025-03-10 09:55:55 +07:00
}
2025-03-10 10:25:01 +07:00
if (!same) exists._quotation.push(quotation);
exists.unpaid = exists._quotation.reduce((a, c) => a + c.value, 0);
2025-03-10 09:55:55 +07:00
return acc;
}, [])
2025-03-10 10:25:01 +07:00
.map((v) => {
return { ...v, _quotation: undefined };
});
2025-03-10 09:55:55 +07:00
}
2025-03-04 11:27:15 +07:00
}