feat(reports): reports module + /inicio + edo-cuenta-datos prefill
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m35s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s

- New reports backend (registry, service, controller, outputs, types)
  with catalog endpoint + slug/CSV/XLSX/PDF/print outputs.
- /reportes catalog + /reportes/[slug] runner; ReportRunner + ContextReports
  components wire pre-filtered links from domain pages.
- Fix: /reportes/[slug] now reads searchParams and forwards initialParams to
  ReportRunner so /reportes/edo-cuenta-datos?customerId=... auto-runs
  instead of dropping the id and forcing a manual customer search.
- /inicio landing page; root + login redirect to /inicio.
- Company header env vars + logo asset for PDF/print rendering.
- exceljs + pdfkit deps.
This commit is contained in:
2026-07-23 23:20:41 -07:00
parent 921a47cbaa
commit 8802f08d4f
30 changed files with 4424 additions and 6 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+3
View File
@@ -21,7 +21,9 @@
"argon2": "^0.41.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"exceljs": "^4.4.0",
"express-session": "^1.18.0",
"pdfkit": "^0.15.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2",
@@ -32,6 +34,7 @@
"@nestjs/testing": "^10.4.4",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.0",
"@types/pdfkit": "^0.13.5",
"@types/jest": "^29.5.13",
"@types/node": "^20.16.11",
"@types/passport": "^1.0.17",
+2
View File
@@ -11,6 +11,7 @@ import { PropertiesModule } from "./properties/properties.module";
import { BillingModule } from "./billing/billing.module";
import { BankModule } from "./bank/bank.module";
import { OpsModule } from "./ops/ops.module";
import { ReportsModule } from "./reports/reports.module";
import { AppController } from "./app.controller";
@Module({
@@ -27,6 +28,7 @@ import { AppController } from "./app.controller";
BillingModule,
BankModule,
OpsModule,
ReportsModule,
],
controllers: [AppController],
})
+103
View File
@@ -0,0 +1,103 @@
/**
* Company info used on every report header (PDF + print). Read from
* the environment so the office can edit it without a code change —
* the .env.example file lists the keys; defaults below are placeholders
* the office should override for production.
*
* Single source of truth: the API renders the header. The web header
* (login + AppShell) still reads the static "Jorge Cuadros & Asociados"
* strings for now — those are visual brand, the API's COMPANY_INFO
* block is the legal/locator block on printed documents.
*/
import * as fs from "node:fs";
import * as path from "node:path";
export interface CompanyInfo {
name: string;
/** Street address — line 1. */
addressLine1: string;
/** Street address — line 2 (suite, floor, etc.). Optional. */
addressLine2: string;
/** "City, State, ZIP, Country" — single line. */
cityState: string;
phone: string;
email: string;
/** Mexican tax ID ("RFC"). Optional. */
taxId: string;
website: string;
/** Absolute path to the logo PNG. Null when missing — renderers fall
* back to a text mark. */
logoPath: string | null;
/** Logo buffer + intrinsic size, eagerly loaded so the PDF renderer
* doesn't do a sync read on every report. Null when no logo. */
logo: { buffer: Buffer; width: number; height: number } | null;
}
function envOr(key: string, fallback: string): string {
const v = process.env[key];
return v && v.trim() ? v : fallback;
}
function resolveLogoPath(): string | null {
const explicit = process.env.COMPANY_LOGO_PATH;
if (explicit) {
return fs.existsSync(explicit) ? explicit : null;
}
// Default: look in apps/api/assets/company_logo.png (copied from
// apps/web/public/images/company_logo.png — single canonical image
// kept in lock-step; see .env.example for the override path).
const candidates = [
path.resolve(__dirname, "..", "..", "assets", "company_logo.png"),
path.resolve(__dirname, "..", "..", "..", "web", "public", "images", "company_logo.png"),
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return null;
}
let cached: CompanyInfo | null = null;
export function getCompanyInfo(): CompanyInfo {
if (cached) return cached;
const logoPath = resolveLogoPath();
let logo: CompanyInfo["logo"] = null;
if (logoPath) {
try {
const buf = fs.readFileSync(logoPath);
// Intrinsic PNG size: read IHDR (bytes 16-23 of the file).
// Width = BE uint32 at offset 16, height = BE uint32 at offset 20.
const w =
logoPath.endsWith(".png") && buf.length >= 24
? buf.readUInt32BE(16)
: 0;
const h =
logoPath.endsWith(".png") && buf.length >= 24
? buf.readUInt32BE(20)
: 0;
logo = { buffer: buf, width: w, height: h };
} catch {
logo = null;
}
}
cached = {
name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"),
addressLine1: envOr(
"COMPANY_ADDRESS_LINE1",
"Av. Revolución 1234, Int. 5",
),
addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""),
cityState: envOr(
"COMPANY_CITY_STATE",
"Tijuana, Baja California 22000, México",
),
phone: envOr("COMPANY_PHONE", "(664) 000-0000"),
email: envOr("COMPANY_EMAIL", "contacto@jorgecuadros.local"),
taxId: envOr("COMPANY_TAX_ID", ""),
website: envOr("COMPANY_WEBSITE", "jorgecuadros.local"),
logoPath,
logo,
};
return cached;
}
+433
View File
@@ -0,0 +1,433 @@
/**
* Output renderers for the reports module.
*
* Every report's `run` returns `{ columns, rows, totals?, subtitle? }`.
* CSV/XLSX/PDF all derive from the same shape so adding a report = one
* registry entry, no per-format template.
*
* PDF uses pdfkit. The statement format (edo-cuenta-datos) uses a
* different layout than the tabular one — handled inline.
*/
// pdfkit exports its constructor via `module.exports = PDFDocument`, so a
// namespace import gets the type, and `import = require()` gets the value.
import PDFDocument = require("pdfkit");
import * as ExcelJS from "exceljs";
import type { ColumnDef, ReportResult } from "./reports.types";
import { getCompanyInfo } from "./company";
type Doc = PDFKit.PDFDocument;
/* ----------------------------------------------------------------- CSV */
function csvCell(v: unknown): string {
if (v === null || v === undefined) return "";
const s = String(v);
if (s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r")) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
export function renderCsv(columns: ColumnDef[], result: ReportResult): string {
const headers = columns.map((c) => csvCell(c.label)).join(",");
const lines = result.rows.map((r) =>
columns
.map((c) => {
const v = r[c.key];
if (typeof v === "number") return v;
return csvCell(v);
})
.join(","),
);
const totals: string[] = [];
if (result.totals) {
for (const [k, v] of Object.entries(result.totals)) {
totals.push(csvCell(k), csvCell(v));
}
}
return [headers, ...lines, ...(totals.length ? [totals.join(",")] : [])].join(
"\n",
);
}
/* ----------------------------------------------------------------- XLSX */
export async function renderXlsx(
columns: ColumnDef[],
result: ReportResult,
): Promise<Buffer> {
const wb = new ExcelJS.Workbook();
wb.creator = "Jorge Cuadros & Asociados";
const ws = wb.addWorksheet("Reporte", {
views: [{ state: "frozen", ySplit: 1 }],
});
ws.columns = columns.map((c) => ({
header: c.label,
key: c.key,
width: Math.max(10, Math.min(40, (c.label.length + 2) * 1.2)),
}));
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFE2EDE9" }, // brand-tint
};
for (const row of result.rows) {
ws.addRow(row);
}
// Number formatting for money columns.
for (const col of columns) {
if (col.type === "money" || col.type === "number") {
ws.getColumn(col.key).numFmt =
col.type === "money" ? "#,##0.00" : "#,##0";
ws.getColumn(col.key).alignment = { horizontal: "right" };
}
}
if (result.totals) {
const last = ws.addRow({});
let i = 1;
for (const [k, v] of Object.entries(result.totals)) {
const cell = ws.getCell(last.number, i);
cell.value = `${k}: ${v}`;
cell.font = { bold: true };
i++;
}
}
const buf = await wb.xlsx.writeBuffer();
return Buffer.from(buf);
}
/* ----------------------------------------------------------------- PDF */
const BRAND = "#0c322d";
const ACCENT = "#bf5a34";
const MUTED = "#756c5c";
const LINE = "#e4dccb";
function fmtMoney(v: unknown): string {
if (v === null || v === undefined || v === "") return "";
const n = Number(v);
if (!Number.isFinite(n)) return String(v);
return n.toLocaleString("es-MX", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function pdfRow(
doc: Doc,
y: number,
cols: Array<{ label: string; width: number; align?: "left" | "right" }>,
values: Array<{ text: string; align?: "left" | "right" }>,
x: number,
): number {
let cx = x;
for (let i = 0; i < cols.length; i++) {
const c = cols[i];
const v = values[i] ?? { text: "" };
const align = v.align ?? c.align ?? "left";
const w = c.width;
doc
.font("Helvetica")
.fontSize(9)
.fillColor("#211d17")
.text(v.text, cx, y, {
width: w - 4,
align,
ellipsis: true,
lineBreak: false,
height: 16,
});
cx += w;
}
return y + 18;
}
export function renderPdf(
columns: ColumnDef[],
result: ReportResult,
title: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({
size: "LETTER",
layout: "landscape",
margins: { top: 96, bottom: 56, left: 48, right: 48 },
bufferPages: true,
info: {
Title: title,
Author: "Jorge Cuadros & Asociados",
Subject: "Reporte",
Creator: "Jorge Cuadros Platform — Reports module",
},
});
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const company = getCompanyInfo();
const pageW = doc.page.width - 96;
/** The header is repeated on every page (via addPage + manual draw). */
const drawHeader = () => {
// Background bar (brand pine) for the masthead.
doc.rect(0, 0, doc.page.width, 60).fill(BRAND);
// Logo, fitted to a 40px box, with 8px padding.
let textX = 48;
if (company.logo) {
const targetH = 40;
const scale = targetH / company.logo.height;
const w = company.logo.width * scale;
doc.image(company.logo.buffer, 48, 10, { height: targetH });
textX = 48 + w + 14;
}
// Company name (large) + "Reporte" tag below it.
doc
.fillColor("#f5f1e8")
.font("Helvetica-Bold")
.fontSize(15)
.text(company.name, textX, 14, { width: pageW - (textX - 48), lineBreak: false });
doc
.font("Helvetica")
.fontSize(8)
.fillColor("#cde0db")
.text("Reporte", textX, 36, { lineBreak: false });
// Right-aligned company locator (address + phone + email).
const rightLines = [
company.addressLine1,
company.addressLine2,
[company.cityState].filter(Boolean).join(" · "),
[company.phone, company.email].filter(Boolean).join(" · "),
company.taxId ? `RFC: ${company.taxId}` : "",
].filter(Boolean);
doc.font("Helvetica").fontSize(8).fillColor("#cde0db");
let ry = 12;
for (const line of rightLines) {
doc.text(line, 48, ry, {
width: pageW,
align: "right",
lineBreak: false,
ellipsis: true,
});
ry += 10;
}
// Thin accent line under the masthead.
doc.rect(0, 60, doc.page.width, 2).fill(ACCENT);
// Title + subtitle + printed-at.
doc
.font("Helvetica-Bold")
.fontSize(15)
.fillColor(BRAND)
.text(title, 48, 72, { lineBreak: false });
let metaY = 92;
if (result.subtitle) {
doc
.font("Helvetica")
.fontSize(9)
.fillColor(MUTED)
.text(result.subtitle, 48, metaY, { lineBreak: false });
metaY += 12;
}
const printedAt = new Date().toLocaleString("es-MX");
doc
.font("Helvetica")
.fontSize(8)
.fillColor(MUTED)
.text(`Impreso: ${printedAt}`, 48, metaY, { lineBreak: false });
};
drawHeader();
// Column widths: distribute page width minus margins, weighted.
const totalW = columns.reduce((s, c) => s + (c.width ?? 12), 0);
const cols = columns.map((c) => ({
label: c.label,
width: ((c.width ?? 12) / totalW) * pageW,
align: c.align,
}));
let y = 130;
const drawTableHeader = () => {
doc.rect(48, y, pageW, 18).fill("#faf6ee");
y = pdfRow(
doc,
y + 4,
cols,
cols.map((c) => ({ text: c.label, align: c.align })),
48,
);
doc
.moveTo(48, y)
.lineTo(48 + pageW, y)
.strokeColor(LINE)
.lineWidth(0.5)
.stroke();
};
drawTableHeader();
// Body rows.
for (const r of result.rows) {
if (y > doc.page.height - 64) {
doc.addPage({ layout: "landscape", margins: { top: 96, bottom: 56, left: 48, right: 48 } });
drawHeader();
y = 130;
drawTableHeader();
}
const vals = columns.map((c) => {
const v = r[c.key];
const text = c.type === "money" ? fmtMoney(v) : v == null ? "" : String(v);
return { text, align: c.align };
});
y = pdfRow(doc, y + 4, cols, vals, 48);
doc
.moveTo(48, y)
.lineTo(48 + pageW, y)
.strokeColor("#e4dccb")
.lineWidth(0.4)
.stroke();
}
// Totals.
if (result.totals) {
y += 6;
doc.rect(48, y, pageW, 18).fill(ACCENT);
doc
.font("Helvetica-Bold")
.fontSize(9)
.fillColor("#f5f1e8")
.text(
Object.entries(result.totals)
.map(([k, v]) => `${k}: ${v}`)
.join(" · "),
52,
y + 5,
{ width: pageW - 8, align: "left" },
);
}
doc.end();
});
}
/* ----------------------------------------------------------------- print (HTML) */
/**
* Print-stylesheet-friendly HTML. The web app's print stylesheet hides
* nav, but otherwise this is a plain table the browser paginates itself.
*
* The header carries the company info (logo + name + locator) so printed
* pages stand alone — staff can hand one to a customer and the office
* identification is on every sheet, not buried in the cover page.
*/
export function renderPrintHtml(
columns: ColumnDef[],
result: ReportResult,
title: string,
): string {
const company = getCompanyInfo();
const head = (label: string, align?: "left" | "right") =>
`<th style="text-align:${align ?? "left"};padding:6px 8px;border-bottom:2px solid #0c322d;background:#faf6ee;font-size:11px">${escapeHtml(label)}</th>`;
const cell = (v: unknown, c: ColumnDef) => {
const text = c.type === "money" ? fmtMoney(v) : v == null ? "" : String(v);
const align = c.align ?? "left";
return `<td style="text-align:${align};padding:4px 8px;border-bottom:1px solid #e4dccb;font-size:11px;${c.type === "money" ? "font-variant-numeric:tabular-nums" : ""}">${escapeHtml(text)}</td>`;
};
const rows = result.rows
.map(
(r) =>
`<tr>${columns
.map((c) => cell(r[c.key], c))
.join("")}</tr>`,
)
.join("");
const totals = result.totals
? `<tr><td colspan="${columns.length}" style="padding:8px;background:#bf5a34;color:#f5f1e8;font-weight:600;font-size:11px">${Object.entries(
result.totals,
)
.map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`)
.join(" &nbsp;·&nbsp; ")}</td></tr>`
: "";
// Logo embedded as base64 data URL — the print page is opened as a
// new tab and printed standalone, so a relative path to the web app
// wouldn't resolve when launched outside the web's origin.
const logoDataUrl = company.logo
? `data:image/png;base64,${company.logo.buffer.toString("base64")}`
: null;
const locatorLines = [
company.addressLine1,
company.addressLine2,
company.cityState,
[company.phone, company.email].filter(Boolean).join(" · "),
company.taxId ? `RFC: ${company.taxId}` : "",
].filter(Boolean);
return `<!doctype html>
<html lang="es"><head>
<meta charset="utf-8" />
<title>${escapeHtml(title)}${escapeHtml(company.name)}</title>
<style>
@page { size: letter landscape; margin: 0.5in; }
body { font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif; color: #211d17; margin: 0; }
.masthead { display: flex; align-items: flex-start; gap: 16px; padding: 12px 16px; background: #0c322d; color: #f5f1e8; border-radius: 6px 6px 0 0; }
.masthead-logo { flex: 0 0 auto; }
.masthead-logo img { display: block; height: 56px; width: auto; }
.masthead-text { flex: 1; min-width: 0; }
.masthead-name { font-family: Georgia, "Times New Roman", serif; font-size: 20px; font-weight: 600; line-height: 1.1; }
.masthead-tag { font-size: 11px; color: #cde0db; margin-top: 2px; text-transform: uppercase; letter-spacing: 0.06em; }
.masthead-locator { font-size: 10px; color: #cde0db; text-align: right; line-height: 1.35; white-space: nowrap; }
.accent { height: 3px; background: #bf5a34; }
.head { padding: 12px 4px 8px; }
.head h1 { font-family: Georgia, "Times New Roman", serif; font-size: 18px; margin: 0; color: #0c322d; }
.head p { font-size: 11px; color: #756c5c; margin: 2px 0 0; }
table { width: 100%; border-collapse: collapse; }
@media print {
.noprint { display: none; }
.masthead { border-radius: 0; }
}
.noprint { padding: 8px 0; }
.noprint button { padding: 6px 12px; background: #0c322d; color: #f5f1e8; border: 0; border-radius: 4px; cursor: pointer; font-size: 12px; }
.footer { margin-top: 16px; font-size: 9px; color: #756c5c; border-top: 1px solid #e4dccb; padding-top: 6px; display: flex; justify-content: space-between; }
</style>
</head><body>
<div class="noprint"><button onclick="window.print()">Imprimir / Guardar PDF</button></div>
<div class="masthead">
${logoDataUrl ? `<div class="masthead-logo"><img src="${logoDataUrl}" alt="" /></div>` : ""}
<div class="masthead-text">
<div class="masthead-name">${escapeHtml(company.name)}</div>
<div class="masthead-tag">Reporte</div>
</div>
<div class="masthead-locator">
${locatorLines.map((l) => escapeHtml(l)).join("<br/>")}
${company.website ? `<br/>${escapeHtml(company.website)}` : ""}
</div>
</div>
<div class="accent"></div>
<div class="head">
<h1>${escapeHtml(title)}</h1>
${result.subtitle ? `<p>${escapeHtml(result.subtitle)}</p>` : ""}
<p>Impreso: ${new Date().toLocaleString("es-MX")}</p>
</div>
<table>
<thead><tr>${columns.map((c) => head(c.label, c.align)).join("")}</tr></thead>
<tbody>${rows}${totals}</tbody>
</table>
<div class="footer">
<span>${escapeHtml(company.name)} · ${escapeHtml(company.phone)} · ${escapeHtml(company.email)}</span>
<span>${escapeHtml(title)}</span>
</div>
</body></html>`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
+130
View File
@@ -0,0 +1,130 @@
import {
Body,
Controller,
Get,
Header,
Param,
Post,
Query,
Res,
UseGuards,
} from "@nestjs/common";
import type { Response } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { ReportsService } from "./reports.service";
import {
renderCsv,
renderPdf,
renderPrintHtml,
renderXlsx,
} from "./outputs";
import { findReport } from "./reports.registry";
/**
* Reports routes. Every report is dispatched by slug; outputs are
* differentiated by `?format=...` (default `json`). Reads only — gated
* by AuthenticatedGuard alone, like every other read in the app.
*/
@UseGuards(AuthenticatedGuard)
@Controller("reports")
export class ReportsController {
constructor(private readonly reports: ReportsService) {}
/** Catalog of all registered reports (the /reportes index). */
@Get()
catalog() {
return { items: this.reports.catalog() };
}
/** Run a report and return the JSON result (rows + totals + the def's columns). */
@Get(":slug")
async runJson(
@Param("slug") slug: string,
@Query() query: Record<string, string | undefined>,
) {
const def = findReport(slug);
const result = await this.reports.run(slug, query);
return { ...result, columns: def?.columns ?? [] };
}
/** CSV download. */
@Get(":slug/csv")
@Header("Content-Type", "text/csv; charset=utf-8")
async runCsv(
@Param("slug") slug: string,
@Query() query: Record<string, string | undefined>,
@Res() res: Response,
) {
const def = findReport(slug);
const result = await this.reports.run(slug, query);
const filename = `${def?.title ?? slug}-${new Date().toISOString().slice(0, 10)}.csv`;
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename.replace(/[^\wÀ-ſ .-]/g, "_")}"`,
);
res.send(renderCsv(def?.columns ?? [], result));
}
/** XLSX download. */
@Get(":slug/xlsx")
async runXlsx(
@Param("slug") slug: string,
@Query() query: Record<string, string | undefined>,
@Res() res: Response,
) {
const def = findReport(slug);
const result = await this.reports.run(slug, query);
const filename = `${def?.title ?? slug}-${new Date().toISOString().slice(0, 10)}.xlsx`;
const buf = await renderXlsx(def?.columns ?? [], result);
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename.replace(/[^\wÀ-ſ .-]/g, "_")}"`,
);
res.send(buf);
}
/** PDF download. */
@Get(":slug/pdf")
async runPdf(
@Param("slug") slug: string,
@Query() query: Record<string, string | undefined>,
@Res() res: Response,
) {
const def = findReport(slug);
const result = await this.reports.run(slug, query);
const buf = await renderPdf(def?.columns ?? [], result, def?.title ?? slug);
const filename = `${def?.title ?? slug}-${new Date().toISOString().slice(0, 10)}.pdf`;
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename.replace(/[^\wÀ-ſ .-]/g, "_")}"`,
);
res.send(buf);
}
/** Browser-printable HTML view (the user hits Print → Save as PDF). */
@Get(":slug/print")
@Header("Content-Type", "text/html; charset=utf-8")
async runPrint(
@Param("slug") slug: string,
@Query() query: Record<string, string | undefined>,
) {
const def = findReport(slug);
const result = await this.reports.run(slug, query);
return renderPrintHtml(def?.columns ?? [], result, def?.title ?? slug);
}
/** POST a customer-picker-driven report (statement). Mirrors GET to keep
* the param contract simple: same body shape, same response. */
@Post(":slug")
async runPost(
@Param("slug") slug: string,
@Body() body: Record<string, string | undefined>,
) {
return this.reports.run(slug, body);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service";
@Module({
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}
+763
View File
@@ -0,0 +1,763 @@
/**
* The catalog. One entry per report. Adding a new report is one new entry
* here — no new route, no new page, no new component.
*
* Filter behavior to keep in mind:
* - Currency balances are NEVER summed across currencies (912 customers
* carry both MXN and USD; the legacy data has no FX per row, so any
* cross-currency total would be invented). Every report that touches
* the ledger accepts a `currency` filter and reports per currency.
* - Voided transactions must be excluded from totals (NOT_VOIDED). The
* UI still shows them struck-through; the SQL drops them.
* - The legacy `REPORTE DE EFECTIVO` covered cash receipts only. In the
* new schema those are `Transaction` rows with `legacySourceTable` in
* the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash).
*/
import { Prisma } from "@jorgecuadros/database";
import {
intParam,
NOT_VOIDED,
parseDate,
type ReportDef,
} from "./reports.types";
/* ------------------------------------------------------------------ helpers */
function nameOf(c: { name: string; nameMissing: boolean }): string {
return c.nameMissing ? "(sin nombre)" : c.name;
}
/* ------------------------------------------------------------------ reports */
/**
* LISTADO EN ROJO — overdue customers worklist.
* Same data as the receivables worklist with balance=owing, but presented
* as a printable report rather than a paginated browser.
*/
const listadoEnRojo: ReportDef = {
slug: "listado-en-rojo",
title: "Clientes en rojo",
description:
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada, " +
"ordenados del más antiguo al más reciente.",
domain: "estado-cuenta",
legacyName: "LISTADO EN ROJO",
format: "tabular",
params: [
{
key: "currency",
label: "Moneda",
kind: "select",
options: [
{ value: "MXN", label: "MXN" },
{ value: "USD", label: "USD" },
],
defaultValue: "MXN",
},
{
key: "query",
label: "Buscar (nombre o ciudad)",
kind: "text",
placeholder: "Ej. Pérez, Tijuana…",
},
],
columns: [
{ key: "id", label: "#", type: "text" },
{ key: "name", label: "Cliente", type: "text" },
{ key: "city", label: "Ciudad", type: "text" },
{ key: "movements", label: "Movs.", type: "number", align: "right" },
{ key: "balance", label: "Saldo", type: "money", align: "right" },
{ key: "lastMovement", label: "Último movimiento", type: "date" },
],
async run(prisma, p) {
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
const q = p.query?.trim();
const nameFilter = q
? Prisma.sql`AND (c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`
: Prisma.empty;
const bal =
currency === "USD"
? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)`
: Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`;
const rows = await prisma.$queryRaw<
Array<{
id: string;
name: string;
nameMissing: boolean;
city: string | null;
movements: bigint | number | string;
balance: Prisma.Decimal | null;
lastMovement: Date | null;
}>
>`
SELECT c.id, c.name, c.nameMissing, c.city,
COUNT(*) AS movements,
${bal} AS balance,
MAX(t.transactionDate) AS lastMovement
FROM customers c
JOIN transactions t ON t.customerId = c.id
WHERE t.voidedAt IS NULL ${nameFilter}
GROUP BY c.id, c.name, c.nameMissing, c.city
HAVING ${bal} < -0.005
ORDER BY MAX(t.transactionDate) ASC, c.nameMissing ASC, c.name ASC
`;
let totalBalance = new Prisma.Decimal(0);
let totalMovs = 0;
const out = rows.map((r) => {
const b = r.balance ?? new Prisma.Decimal(0);
totalBalance = totalBalance.plus(b);
totalMovs += Number(r.movements);
return {
id: r.id.slice(0, 8),
name: nameOf(r),
city: r.city ?? "—",
movements: Number(r.movements),
balance: b.toFixed(2),
lastMovement: r.lastMovement
? r.lastMovement.toISOString().slice(0, 10)
: "—",
};
});
return {
rows: out,
totals: {
customers: out.length,
movements: totalMovs,
balance: totalBalance.toFixed(2),
currency,
},
subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor`,
};
},
};
/**
* PAGOS NO EFECTUADOS (AGUA / LUZ / TEL).
* Customers enrolled in a service (by PropertyService.kind) with no
* related ledger charge in the last N days. Heuristic: any non-voided
* charge-type transaction in the window counts as "they paid". The
* report groups by property so the same customer with two water meters
* appears once per property.
*/
const pagosNoEfectuados: ReportDef = {
slug: "pagos-no-efectuados",
title: "Pagos no efectuados",
description:
"Clientes con un servicio contratado (agua, luz o teléfono) sin " +
"movimientos de cargo en los últimos N días. Heurística basada en el " +
"servicio registrado en la propiedad y la ausencia de cargos en el " +
"periodo seleccionado.",
domain: "servicios",
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
format: "tabular",
params: [
{
key: "serviceKind",
label: "Servicio",
kind: "select",
options: [
{ value: "WATER", label: "Agua" },
{ value: "ELECTRICITY", label: "Luz" },
{ value: "PHONE", label: "Teléfono" },
],
defaultValue: "WATER",
},
{
key: "days",
label: "Días sin movimiento",
kind: "number",
defaultValue: "60",
},
{
key: "currency",
label: "Moneda",
kind: "select",
options: [
{ value: "MXN", label: "MXN" },
{ value: "USD", label: "USD" },
],
defaultValue: "MXN",
},
],
columns: [
{ key: "customerId", label: "Cliente #", type: "text" },
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "accountNumber", label: "Cuenta / Medidor", type: "text" },
{ key: "lastCharge", label: "Último cargo", type: "date" },
{ key: "balance", label: "Saldo", type: "money", align: "right" },
],
async run(prisma, p) {
const kind = (p.serviceKind ?? "WATER") as
| "WATER"
| "ELECTRICITY"
| "PHONE";
const days = intParam(p, "days", 60, 1, 365);
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
const cutoff = new Date(Date.now() - days * 86400000);
// Customers enrolled in the service on a non-archived property, with no
// charge-type transaction in the window. The subquery picks up
// *anything* the customer paid (any domain, any type) — close enough
// for the staff's "who's overdue" view; the precise per-service match
// would need a per-service typeId taxonomy that doesn't exist in the
// legacy data.
const rows = await prisma.$queryRaw<
Array<{
customerId: string;
customerName: string;
nameMissing: boolean;
propertyId: string;
propertyAddress: string | null;
accountNumber: string | null;
lastCharge: Date | null;
balance: Prisma.Decimal | null;
}>
>`
SELECT
c.id AS customerId,
c.name AS customerName,
c.nameMissing AS nameMissing,
pr.id AS propertyId,
pr.addressLine1 AS propertyAddress,
ps.accountNumber AS accountNumber,
(SELECT MAX(t.transactionDate) FROM transactions t
WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.amount < 0
AND t.transactionDate >= ${cutoff}) AS lastCharge,
(SELECT SUM(t.amount) FROM transactions t
WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.currency = ${currency}) AS balance
FROM property_services ps
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
WHERE ps.kind = ${kind} AND ps.active = 1
AND NOT EXISTS (
SELECT 1 FROM transactions t
WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.amount < 0 AND t.transactionDate >= ${cutoff}
)
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
`;
let totalBalance = new Prisma.Decimal(0);
const out = rows.map((r) => {
const b = r.balance ?? new Prisma.Decimal(0);
totalBalance = totalBalance.plus(b);
return {
customerId: r.customerId.slice(0, 8),
customerName: nameOf({
name: r.customerName,
nameMissing: r.nameMissing,
}),
propertyAddress: r.propertyAddress ?? "—",
accountNumber: r.accountNumber ?? "—",
lastCharge: r.lastCharge
? r.lastCharge.toISOString().slice(0, 10)
: "—",
balance: b.toFixed(2),
};
});
return {
rows: out,
totals: {
rows: out.length,
balance: totalBalance.toFixed(2),
currency,
},
subtitle: `Servicio: ${
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
} · ${days} días · ${out.length} propiedades sin cargo reciente`,
};
},
};
/**
* FALTANTES DE (AGUA / LUZ / TEL).
* Data-quality report: properties enrolled in a service that are missing
* the key identifier the legacy system required (account/meter/route).
* Different `faltante` per service kind in the legacy because the
* service's identifier fields differ; here we flag any of the three
* common identifiers being blank.
*/
const faltantes: ReportDef = {
slug: "faltantes",
title: "Faltantes de datos por servicio",
description:
"Calidad de datos: propiedades con un servicio contratado que no " +
"tienen número de cuenta, medidor o ruta registrado. El reporte que " +
"en la legacy corría como FALTANTES DE AGUA / LUZ / TEL.",
domain: "servicios",
legacyName: "FALTANTES DE AGUA/LUZ/TEL",
format: "tabular",
params: [
{
key: "serviceKind",
label: "Servicio",
kind: "select",
options: [
{ value: "WATER", label: "Agua" },
{ value: "ELECTRICITY", label: "Luz" },
{ value: "PHONE", label: "Teléfono" },
],
defaultValue: "WATER",
},
],
columns: [
{ key: "customerId", label: "Cliente #", type: "text" },
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "missing", label: "Faltante", type: "text" },
{ key: "dueDay", label: "Día de vencimiento", type: "text" },
],
async run(prisma, p) {
const kind = (p.serviceKind ?? "WATER") as
| "WATER"
| "ELECTRICITY"
| "PHONE";
// A row per (property, missing field). The "missing" string describes
// what's blank so the report is self-explanatory when printed.
const rows = await prisma.$queryRaw<
Array<{
customerId: string;
customerName: string;
nameMissing: boolean;
propertyId: string;
propertyAddress: string | null;
dueDay: string | null;
missing: string;
}>
>`
SELECT
c.id AS customerId,
c.name AS customerName,
c.nameMissing AS nameMissing,
pr.id AS propertyId,
pr.addressLine1 AS propertyAddress,
ps.dueDay AS dueDay,
CASE
WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta'
WHEN ps.meterNumber IS NULL OR ps.meterNumber = '' THEN 'Sin número de medidor'
WHEN ps.route IS NULL OR ps.route = '' THEN 'Sin ruta'
ELSE ''
END AS missing
FROM property_services ps
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
WHERE ps.kind = ${kind} AND ps.active = 1
AND (
ps.accountNumber IS NULL OR ps.accountNumber = ''
OR ps.meterNumber IS NULL OR ps.meterNumber = ''
OR ps.route IS NULL OR ps.route = ''
)
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
`;
const out = rows.map((r) => ({
customerId: r.customerId.slice(0, 8),
customerName: nameOf({
name: r.customerName,
nameMissing: r.nameMissing,
}),
propertyAddress: r.propertyAddress ?? "—",
missing: r.missing,
dueDay: r.dueDay ?? "—",
}));
return {
rows: out,
totals: { rows: out.length },
subtitle: `Servicio: ${
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
} · ${out.length} propiedades con datos faltantes`,
};
},
};
/**
* REPORTE DE EFECTIVO — cash reconciliation.
* Credits in the EFECTIVO* legacy source tables OR with no cheque number
* (true cash) within a date range. Excludes voided rows. Matches the
* shape of the legacy REPORTE DE EFECTIVO report.
*/
const reporteDeEfectivo: ReportDef = {
slug: "reporte-de-efectivo",
title: "Reporte de efectivo",
description:
"Recibos de efectivo en el periodo seleccionado. Cubre los abonos " +
"provenientes de las tablas legacy EFECTIVO* y los créditos sin " +
"número de cheque (efectivo real). El match del reporte original.",
domain: "chequera",
legacyName: "REPORTE DE EFECTIVO",
format: "tabular",
params: [
{ key: "from", label: "Desde", kind: "date" },
{ key: "to", label: "Hasta", kind: "date", endOfDay: true },
{
key: "currency",
label: "Moneda",
kind: "select",
options: [
{ value: "MXN", label: "MXN" },
{ value: "USD", label: "USD" },
],
defaultValue: "MXN",
},
],
columns: [
{ key: "date", label: "Fecha", type: "date" },
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "concept", label: "Concepto", type: "text" },
{ key: "source", label: "Origen", type: "text" },
{ key: "amount", label: "Monto", type: "money", align: "right" },
],
async run(prisma, p) {
const from = parseDate(p.from);
const to = parseDate(p.to, true);
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
const ands: Prisma.TransactionWhereInput[] = [
NOT_VOIDED,
{ amount: { gt: 0 } },
{ currency },
{
OR: [
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
{
AND: [
{ checkNumber: null },
{ legacySourceTable: { not: "CHEQUE FM3" } },
],
},
],
},
];
if (from || to) {
ands.push({
transactionDate: {
...(from ? { gte: from } : {}),
...(to ? { lte: to } : {}),
},
});
}
const rows = await prisma.transaction.findMany({
where: { AND: ands },
orderBy: { transactionDate: "asc" },
select: {
transactionDate: true,
amount: true,
reference: true,
checkNumber: true,
message: true,
legacySourceTable: true,
type: { select: { nameEs: true, nameEn: true } },
customer: { select: { name: true, nameMissing: true } },
},
});
let total = new Prisma.Decimal(0);
const out = rows.map((r) => {
total = total.plus(r.amount);
return {
date: r.transactionDate.toISOString().slice(0, 10),
customerName: nameOf(r.customer),
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
source: r.legacySourceTable ?? "—",
amount: r.amount.toFixed(2),
};
});
return {
rows: out,
totals: {
rows: out.length,
total: total.toFixed(2),
currency,
},
subtitle: `Efectivo · ${currency} · ${out.length} recibos${
from ? ` desde ${p.from}` : ""
}${to ? ` hasta ${p.to}` : ""}`,
};
},
};
/**
* VIGENTE (LIC / INCEN / MULT / …) — policies up for renewal.
* Wraps the policy listing with status=expiring and a policy-type filter,
* sorted by soonest expiry. The legacy VIGENTE LIC / INCEN / MULT
* reports are the same data; the new filter is a dropdown.
*/
const vigente: ReportDef = {
slug: "vigente",
title: "Pólizas por vencer",
description:
"Pólizas que vencen en los próximos N días, filtradas por ramo. " +
"Equivalente a los reportes VIGENTE LIC / INCEN / MULT de la legacy.",
domain: "polizas",
legacyName: "VIGENTE LIC/INCEN/MULT",
format: "tabular",
params: [
{
key: "typeName",
label: "Ramo",
kind: "select",
options: [
{ value: "LICENCIAS", label: "Licencias" },
{ value: "INCENDIO", label: "Incendio" },
{ value: "MULT", label: "Multirriesgo" },
{ value: "MCA2", label: "MCA2 (auto)" },
{ value: "ME", label: "ME" },
{ value: "MF", label: "MF" },
{ value: "RC", label: "RC" },
{ value: "INCEN", label: "Incen" },
{ value: "TAMPL", label: "TAMPL" },
{ value: "FAMILIAR", label: "Familiar" },
],
defaultValue: "LICENCIAS",
},
{
key: "days",
label: "Ventana (días)",
kind: "number",
defaultValue: "30",
},
],
columns: [
{ key: "policyNumber", label: "Póliza", type: "text" },
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "provider", label: "Aseguradora", type: "text" },
{ key: "agent", label: "Agente", type: "text" },
{ key: "from", label: "Desde", type: "date" },
{ key: "to", label: "Vence", type: "date" },
{ key: "daysToExpire", label: "Días", type: "number", align: "right" },
{ key: "premium", label: "Prima neta", type: "money", align: "right" },
],
async run(prisma, p) {
const typeName = p.typeName ?? "LICENCIAS";
const days = intParam(p, "days", 30, 1, 365);
const now = new Date();
const today = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
const soon = new Date(today.getTime() + days * 86400000);
const rows = await prisma.policy.findMany({
where: {
policyType: { name: typeName },
archivedAt: null,
policyTo: { gte: today, lte: soon },
},
orderBy: { policyTo: "asc" },
select: {
policyNumber: true,
policyFrom: true,
policyTo: true,
netPremium: true,
agentName: true,
customer: { select: { name: true, nameMissing: true } },
insuranceProvider: { select: { name: true } },
},
});
let totalPremium = new Prisma.Decimal(0);
const out = rows.map((r) => {
const daysTo = r.policyTo
? Math.round(
(r.policyTo.getTime() - today.getTime()) / 86400000,
)
: 0;
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
return {
policyNumber: r.policyNumber,
customerName: nameOf(r.customer),
provider: r.insuranceProvider?.name ?? "—",
agent: r.agentName ?? "—",
from: r.policyFrom ? r.policyFrom.toISOString().slice(0, 10) : "—",
to: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—",
daysToExpire: daysTo,
premium: r.netPremium ? r.netPremium.toFixed(2) : "0.00",
};
});
return {
rows: out,
totals: {
rows: out.length,
premium: totalPremium.toFixed(2),
},
subtitle: `Ramo: ${typeName} · ${days} días · ${out.length} pólizas por vencer`,
};
},
};
/**
* EDO CUENTA DATOS — per-customer account statement.
* Wraps the existing BillingService.statement() output. The full layout
* (header, balance per currency, by-domain split, by-type breakdown,
* full movement list with running balance) is rendered by the statement
* page; this report is the same data with print/PDF/CSV/XLSX outputs.
*/
const edoCuentaDatos: ReportDef = {
slug: "edo-cuenta-datos",
title: "Estado de cuenta",
description:
"Estado de cuenta de un cliente: saldos por moneda, desglose por " +
"ramo y concepto, y el historial completo de movimientos con saldo " +
"corrido. El reporte del cliente final.",
domain: "estado-cuenta",
legacyName: "EDO CUENTA DATOS",
format: "statement",
params: [
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
],
columns: [
// Statement rows carry synthetic `__kind` discriminators instead of
// column keys; the runner renders the special cases inline. These
// columns drive CSV/XLSX when the user wants a flat movement export.
{ key: "date", label: "Fecha", type: "date" },
{ key: "concept", label: "Concepto", type: "text" },
{ key: "reference", label: "Referencia", type: "text" },
{ key: "amount", label: "Cargo / Abono", type: "money", align: "right" },
{ key: "balanceAfter", label: "Saldo", type: "money", align: "right" },
],
async run(prisma, p) {
const customerId = p.customerId;
if (!customerId) {
return { rows: [], subtitle: "Selecciona un cliente" };
}
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
id: true,
name: true,
nameMissing: true,
addressLine1: true,
city: true,
state: true,
email: true,
phone: true,
},
});
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
// Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter
// as BillingService.statement so the numbers match what the customer
// already sees in /estado-cuenta/[id].
const rows = await prisma.transaction.findMany({
where: {
customerId,
voidedAt: null,
legacySourceTable: {
notIn: [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
"CHEQUE FM3",
"IVA 2015",
],
},
},
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: {
id: true,
transactionDate: true,
domain: true,
amount: true,
currency: true,
reference: true,
period: true,
checkNumber: true,
message: true,
legacySourceTable: true,
type: { select: { nameEs: true, nameEn: true } },
},
});
// Compute running balance per currency, then return newest-first.
const running = new Map<string, Prisma.Decimal>();
const movements = rows.map((r) => {
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
const next = prev.plus(r.amount);
running.set(r.currency, next);
return {
date: r.transactionDate.toISOString().slice(0, 10),
domain: r.domain,
currency: r.currency,
reference: r.reference ?? "",
period: r.period ?? "",
checkNumber: r.checkNumber ?? "",
concept: r.type?.nameEs ?? r.type?.nameEn ?? "—",
amount: r.amount.toFixed(2),
balanceAfter: next.toFixed(2),
};
});
movements.reverse();
// Per-currency summary + per-domain breakdown.
const perCurrency = new Map<
string,
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
>();
for (const r of rows) {
const c =
perCurrency.get(r.currency) ??
{
currency: r.currency,
charges: new Prisma.Decimal(0),
credits: new Prisma.Decimal(0),
count: 0,
};
c.count += 1;
if (r.amount.lessThan(0)) c.charges = c.charges.plus(r.amount);
else c.credits = c.credits.plus(r.amount);
perCurrency.set(r.currency, c);
}
return {
rows: [
{
__kind: "header",
name: nameOf(customer),
address: customer.addressLine1 ?? "",
city: [customer.city, customer.state].filter(Boolean).join(", "),
phone: customer.phone ?? "",
email: customer.email ?? "",
},
...[...perCurrency.values()].map((c) => ({
__kind: "summary",
currency: c.currency,
charges: c.charges.toFixed(2),
credits: c.credits.toFixed(2),
balance: c.charges.plus(c.credits).toFixed(2),
count: c.count,
})),
{ __kind: "movements-header" },
...movements,
],
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
};
},
};
/* ------------------------------------------------------------------ export */
export const REPORTS: ReportDef[] = [
listadoEnRojo,
pagosNoEfectuados,
faltantes,
reporteDeEfectivo,
vigente,
edoCuentaDatos,
];
export function findReport(slug: string): ReportDef | undefined {
return REPORTS.find((r) => r.slug === slug);
}
+45
View File
@@ -0,0 +1,45 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { findReport, REPORTS } from "./reports.registry";
import type { ReportDef } from "./reports.types";
/**
* The reports service. Two responsibilities:
* 1. Run a report by slug with the given params — just dispatch.
* 2. Return the catalog for the /reportes index page.
*
* Output rendering (CSV/XLSX/PDF/HTML print) lives in `outputs.ts`; this
* service is data only. The controller maps URLs to (slug, format) and
* hands the result to outputs.
*/
@Injectable()
export class ReportsService {
constructor(private readonly prisma: PrismaService) {}
/** List every registered report, in display order. */
catalog(): Array<{
slug: string;
title: string;
description: string;
domain: string;
legacyName: string | null;
format: string;
params: ReportDef["params"];
}> {
return REPORTS.map((r) => ({
slug: r.slug,
title: r.title,
description: r.description,
domain: r.domain,
legacyName: r.legacyName,
format: r.format,
params: r.params,
}));
}
async run(slug: string, params: Record<string, string | undefined>) {
const def = findReport(slug);
if (!def) throw new NotFoundException(`Reporte "${slug}" no encontrado`);
return def.run(this.prisma, params);
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* The reports module — plan step 10.
*
* Each "report" is one entry in `reports.registry.ts`. The entry declares
* its slug (URL id), title, what filters it accepts, what columns it
* returns, and a `run` function that produces the data from Prisma. The
* service dispatches on slug; the controller exposes JSON + CSV + XLSX +
* PDF + HTML print; the catalog endpoint exposes the registry itself so
* the `/reportes` page can render the same data.
*
* Output philosophy: a report returns a uniform shape — `columns` (typed
* schema) + `rows` (any[] of values matching the column types) + `totals`
* (record of column key → summary value). All three output formats
* (CSV/XLSX/PDF/print) derive from this same shape so adding a new
* report is one entry, never a per-format template.
*/
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
/** Top-level grouping for the catalog page; matches the existing nav. */
export type ReportDomain =
| "clientes"
| "polizas"
| "servicios"
| "estado-cuenta"
| "chequera";
/** How the runner should render rows: a grid, or a per-customer statement. */
export type ReportFormat = "tabular" | "statement";
/** Filter controls the report's UI should render. */
export type ParamDef =
| {
key: string;
label: string;
kind: "text" | "number";
placeholder?: string;
defaultValue?: string;
}
| {
key: string;
label: string;
kind: "date";
/** Inclusive bound, true for `to`, false for `from`. */
endOfDay?: boolean;
defaultValue?: string;
}
| {
key: string;
label: string;
kind: "select";
options: { value: string; label: string }[];
defaultValue?: string;
}
| {
key: string;
label: string;
kind: "customer-picker";
};
/** One column of the output table. */
export interface ColumnDef {
key: string;
label: string;
/** Render hint for the on-screen + print table. */
type: "text" | "number" | "money" | "date";
/** Right-align numbers/money; default false (left). */
align?: "left" | "right";
/** Used for column-width hints in the print/PDF layout. */
width?: number;
}
/** Shape every report's `run` resolves to. Columns come from the def. */
export interface ReportResult {
rows: Array<Record<string, unknown>>;
totals?: Record<string, string | number>;
/** Optional free-form subtitle for print/PDF (e.g. date range, scope). */
subtitle?: string;
}
/** A report's static declaration. */
export interface ReportDef {
slug: string;
title: string;
description: string;
domain: ReportDomain;
/** The original Access report name (per docs/LEGACY_DATABASES_OBJECTS.md)
* for traceability. Null when this is a new report with no legacy equiv. */
legacyName: string | null;
format: ReportFormat;
params: ParamDef[];
columns: ColumnDef[];
/**
* Run the report. Receives the Prisma client and the validated params
* record (keys are the `key` from ParamDef, values are the strings the
* runner collected; numeric/date params arrive as strings — the report
* parses them). Must apply the same NOT_VOIDED filter on transactions as
* the billing module so totals match.
*/
run: (
prisma: PrismaService,
params: Record<string, string | undefined>,
) => Promise<ReportResult>;
}
/** A typed bag of helpers for the report functions. */
export interface ReportCtx {
prisma: PrismaService;
params: Record<string, string | undefined>;
}
/** Helper: a `YYYY-MM-DD` bound; unparseable is undefined. */
export function parseDate(
v: string | undefined,
endOfDay = false,
): Date | undefined {
if (!v) return undefined;
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
return Number.isNaN(d.getTime()) ? undefined : d;
}
/** Helper: integer param with default. */
export function intParam(
p: Record<string, string | undefined>,
key: string,
def: number,
min = 1,
max = 1000,
): number {
const n = Number(p[key]);
if (!Number.isFinite(n)) return def;
return Math.min(max, Math.max(min, Math.round(n)));
}
/** Helper: not-voided filter, shared with billing.service. */
export const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
export const NOT_VOIDED_BANK: Prisma.BankTransactionWhereInput = {
voidedAt: null,
};