Files
jorgecuadros-platform/apps/api/src/reports/outputs.ts
T
rmancinasandClaude Opus 5.5 031b13f8c8
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m44s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m16s
fix(reports): balances the statement agrees with, NUMid on every report
From the 2026-10-08 meeting.

- Clientes en rojo summed every row a customer ever had, so the opening
  balance's history counted twice, cash receipts twice and the 2025
  archive on top: 195 customers in the red against 22 real ones. It now
  takes the balance scope balances() uses (extracted as balanceScopeSql),
  sorts by most recent real movement, and filters by ramo, date, minimum
  owed and inactive customers (status = 0, the internal accounts).
- Pagos no efectuados asked "no charge of any kind in N days"; legacy asks
  "no row of THAT service's concept in the period". Rewritten to legacy's
  question, per customer, defaulting to one billing cycle back. It also
  passed ELECTRICITY/PHONE, which are not ServiceKind values, so Luz and
  Telefono never returned a row. Sin fondos rows show as their own status.
- Faltantes demanded account, meter AND route of every kind; water and
  phone have no route. Now the legacy fields per kind.
- Reporte de efectivo counted every credit BALANCE FORWARD as cash
  (958 of 1093 rows on dev).
- Every report carries the legacy NUMid; policy reports add the
  insurance file number. The statement header shows it too.
- Printed headers showed placeholder company details: prod sets no
  COMPANY_* variables. Defaults are now the office's real locator, plus a
  US mailing address line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:20:44 -07:00

436 lines
14 KiB
TypeScript

/**
* 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.mailingAddress,
[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.mailingAddress,
[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;");
}