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>>((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, })); } }