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

640 lines
17 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,
User,
} 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";
import { PaymentStatus } from "../generated/kysely/types";
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-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;
}
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-04 11:27:15 +07:00
}