feat(reports): reports module + /inicio + edo-cuenta-datos prefill
- 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:
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(" · ")}</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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user