import config from "../config.json"; import { Customer, CustomerBranch, ProductGroup, QuotationStatus, RequestWorkStatus, PaymentStatus, User, Invoice, CustomerType, } 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"; 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: { 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: { 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: { 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: { 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, 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>>((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([ "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", "Quotation.code as quotationCode", "Quotation.finalPrice as quotationValue", ]) .select(["Payment.paymentStatus"]) .selectAll(["Invoice"]) .distinctOn("Invoice.id"); if (!isSystem(req.user)) { query = query.where(permissionQueryCondCompany(req.user)); } const ret = await query.execute(); const arr = ret.map((item) => { const data: Record = {}; 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; quotationCode: string; quotationValue: number; paymentStatus: PaymentStatus; customerBranch: CustomerBranch & { customer: { customerType: CustomerType } }; }; }); return arr .reduce< { paid: number; unpaid: number; customerBranch: CustomerBranch & { customer: { customerType: CustomerType } }; }[] >((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, paid: item.paymentStatus === "PaymentSuccess" ? item.amount : 0, unpaid: item.paymentStatus !== "PaymentSuccess" ? item.amount : 0, }); } exists[item.paymentStatus === "PaymentSuccess" ? "paid" : "unpaid"] += item.amount; return acc; }, []) .map((v) => { return { ...v, _quotation: undefined }; }); } }