724 lines
20 KiB
TypeScript
724 lines
20 KiB
TypeScript
import config from "../config.json";
|
|
import {
|
|
Customer,
|
|
CustomerBranch,
|
|
QuotationStatus,
|
|
RequestWorkStatus,
|
|
PaymentStatus,
|
|
} from "@prisma/client";
|
|
import { Controller, Get, Query, Request, Route, Security, Tags } from "tsoa";
|
|
import prisma from "../db";
|
|
import { createPermCondition, createQueryPermissionCondition } from "../services/permission";
|
|
import { RequestWithUser } from "../interfaces/user";
|
|
import { precisionRound } from "../utils/arithmetic";
|
|
import dayjs from "dayjs";
|
|
import { json2csv } from "json-2-csv";
|
|
import { isSystem } from "../utils/keycloak";
|
|
import { jsonObjectFrom } from "kysely/helpers/postgres";
|
|
|
|
const permissionCondCompany = createPermCondition((_) => true);
|
|
const permissionQueryCondCompany = createQueryPermissionCondition((_) => 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).then((v) =>
|
|
v.map((v) => ({
|
|
...v,
|
|
customerBranch: {
|
|
...v.customerBranch,
|
|
customerType: v.customerBranch.customer.customerType,
|
|
customer: undefined,
|
|
},
|
|
})),
|
|
),
|
|
{ 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,
|
|
customerBranch: {
|
|
omit: { otpCode: true, otpExpires: true, userId: true },
|
|
include: { customer: true },
|
|
},
|
|
finalPrice: 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,
|
|
amount: v.finalPrice,
|
|
createdAt: v.createdAt,
|
|
updatedAt: v.updatedAt,
|
|
|
|
customerBranch: v.customerBranch,
|
|
}));
|
|
}
|
|
|
|
@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).then((v) =>
|
|
v.map((v) => ({
|
|
...v,
|
|
customerBranch: {
|
|
...v.customerBranch,
|
|
customerType: v.customerBranch.customer.customerType,
|
|
customer: undefined,
|
|
},
|
|
})),
|
|
),
|
|
{
|
|
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,
|
|
quotation: {
|
|
select: {
|
|
customerBranch: {
|
|
omit: { otpCode: true, otpExpires: true, userId: true },
|
|
include: { customer: 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,
|
|
|
|
customerBranch: v.quotation.customerBranch,
|
|
}));
|
|
}
|
|
|
|
@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).then((v) =>
|
|
v.map((v) => ({
|
|
...v,
|
|
customerBranch: {
|
|
...v.customerBranch,
|
|
customerType: v.customerBranch.customer.customerType,
|
|
customer: undefined,
|
|
},
|
|
})),
|
|
),
|
|
{
|
|
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,
|
|
invoice: {
|
|
select: {
|
|
quotation: {
|
|
select: {
|
|
customerBranch: {
|
|
omit: { otpCode: true, otpExpires: true, userId: true },
|
|
include: { customer: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
amount: 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,
|
|
amount: v.amount,
|
|
status: v.paymentStatus,
|
|
createdAt: v.createdAt,
|
|
|
|
customerBranch: v.invoice.quotation.customerBranch,
|
|
}));
|
|
}
|
|
|
|
@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: {
|
|
omit: { otpCode: true, otpExpires: true, userId: true },
|
|
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: ((typeof list)[number]["product"]["productGroup"] & { _count: number })[];
|
|
bySale: ((typeof list)[number]["quotation"]["createdBy"] & { _count: number })[];
|
|
byCustomer: ((typeof list)[number]["quotation"]["customerBranch"] & { _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,
|
|
createdAt: 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 {
|
|
month: v.quotation.createdAt.getMonth() + 1,
|
|
year: v.quotation.createdAt.getFullYear(),
|
|
income,
|
|
expenses,
|
|
netProfit,
|
|
};
|
|
});
|
|
});
|
|
|
|
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;
|
|
} else {
|
|
a.dataset.push(c);
|
|
}
|
|
|
|
a.income += c.income;
|
|
a.expenses += c.expenses;
|
|
a.netProfit += c.netProfit;
|
|
|
|
return a;
|
|
},
|
|
{ income: 0, expenses: 0, netProfit: 0, dataset: [] },
|
|
);
|
|
}
|
|
|
|
@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;
|
|
}
|
|
|
|
@Get("customer-dept")
|
|
async reportCustomerDept(@Request() req: RequestWithUser) {
|
|
let query = prisma.$kysely
|
|
.selectFrom("Invoice")
|
|
.leftJoin("Quotation", "Quotation.id", "Invoice.quotationId")
|
|
.leftJoin("Payment", "Invoice.id", "Payment.invoiceId")
|
|
.leftJoin("CustomerBranch", "CustomerBranch.id", "Quotation.customerBranchId")
|
|
.leftJoin("Customer", "Customer.id", "CustomerBranch.customerId")
|
|
.select((eb) => [
|
|
jsonObjectFrom(
|
|
eb
|
|
.selectFrom("CustomerBranch")
|
|
.select((eb) => [
|
|
jsonObjectFrom(
|
|
eb
|
|
.selectFrom("Customer")
|
|
.selectAll("Customer")
|
|
.whereRef("Customer.id", "=", "CustomerBranch.customerId"),
|
|
).as("customer"),
|
|
])
|
|
.selectAll("CustomerBranch")
|
|
.whereRef("CustomerBranch.id", "=", "Quotation.customerBranchId"),
|
|
).as("customerBranch"),
|
|
])
|
|
.select(["Payment.paymentStatus"])
|
|
.selectAll(["Invoice"])
|
|
.distinctOn("Invoice.id");
|
|
|
|
if (!isSystem(req.user)) {
|
|
query = query.where(permissionQueryCondCompany(req.user));
|
|
}
|
|
|
|
const ret = await query.execute();
|
|
|
|
return ret
|
|
.reduce<
|
|
{
|
|
paid: number;
|
|
unpaid: number;
|
|
customerBranch: CustomerBranch & {
|
|
customer: Customer;
|
|
};
|
|
}[]
|
|
>((acc, item) => {
|
|
const exists = acc.find((v) => v.customerBranch.id === item.customerBranch!.id);
|
|
|
|
if (!item.amount) return acc;
|
|
|
|
if (!exists) {
|
|
return acc.concat({
|
|
customerBranch: item.customerBranch as CustomerBranch & { customer: Customer },
|
|
paid: item.paymentStatus === "PaymentSuccess" ? item.amount : 0,
|
|
unpaid: item.paymentStatus !== "PaymentSuccess" ? item.amount : 0,
|
|
});
|
|
} else {
|
|
exists[item.paymentStatus === "PaymentSuccess" ? "paid" : "unpaid"] += item.amount;
|
|
}
|
|
|
|
return acc;
|
|
}, [])
|
|
.map((v) => ({
|
|
...v,
|
|
customerBranch: {
|
|
...v.customerBranch,
|
|
userId: undefined,
|
|
otpCode: undefined,
|
|
otpExpires: undefined,
|
|
},
|
|
_quotation: undefined,
|
|
}));
|
|
}
|
|
}
|