Implements docs/RECEIPT_CAPTURE_SPEC.md §1, the legacy "Editor" replacement, on top of the single-movement capture from plan step 6. No new abilities: batching and resolving are both capturing. - outstanding (legacy NOPAGO): capture flag, ?outstanding= filter, and POST /billing/:id/resolve-outstanding (gated ledger:create, not ledger:void — resolving completes a capture rather than reversing one). Outstanding rows are excluded from every balance aggregate, matching the legacy SALDOS ULTIMO 0 query's HAVING NOPAGO = 0, but still count in the movement browser's filtered totals. - POST /billing/batch: many customers' receipts against one check, in one $transaction. Deliberately not a persisted batch entity — checkNumber is already a column and grouping by it answers every legacy by-check query. - GET /billing/by-check + a cheque-count report, replacing REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE ALF|COUNT|NUM. Print, PDF, CSV and XLSX come free from the existing /reportes/:slug machinery. - Web: /estado-cuenta/lote (the Editor screen, with live reconciliation against the physical check amount), an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog, plus a top-level "Captura" nav entry. Integration seam for the OCR auto-capture module (spec §2), which is required to post through createBatch rather than writing Transaction rows itself: items[i] maps to lines[i] so postedTransactionId can be zipped back on; opts.refs[i] stamps captureRef with a duplicate-post guard that a voided row deliberately does not block; opts.source is service-level only, so an HTTP client cannot label hand-keyed rows as machine-captured. captureSource/captureRef are nullable so the 40,136 migrated rows stay NULL rather than being mislabelled. Fixes two pre-existing bugs found while building this: - statement() filtered legacySourceTable with `notIn`, which compiles to SQL NOT IN — and `NULL NOT IN (...)` is NULL, so every app-captured movement was invisible on the customer statement (438 rows in the movement browser vs 392 on the statement) while showing everywhere else. This would have made the whole capture feature look broken. - The balances count query omitted the void filter its own page query applied, so the total disagreed with the rows. Nav highlighting now resolves by longest match; the previous first-startsWith logic lit up both the parent and any nested entry. Verified end-to-end against the dev DB, API and browser; all test rows removed afterwards. Also corrects RESUME.md, which documented the dev ports as :3001/:3000 — they are :4501/:4500, from the env files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1073 lines
36 KiB
TypeScript
1073 lines
36 KiB
TypeScript
/**
|
|
* 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`,
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* AVISO DE RENOVACION — insurance renewal notice.
|
|
*
|
|
* Replaces ~40 legacy report clones (one per carrier per coverage tier —
|
|
* `AMPL R RENEW X MES NEW ATLAS 13`, `... QUALITAS ...`, `LIC RENEW X
|
|
* VENCE ATLAS 2013`, etc., see docs/RENEWAL_NOTICES.md) with one
|
|
* parameterized report: pick the ramo, the expiry month/year, which
|
|
* notice generation (1st/2nd/3rd, mirroring the legacy RENEW/RENEW2/
|
|
* RENEW3 escalation), and optionally a carrier filter.
|
|
*
|
|
* The legacy reports hardcoded per-policy figures (deductible, CSL limit,
|
|
* premium) as static label text re-typed by hand for every new rate/
|
|
* carrier clone. Here they're read from real columns / `coveragesJson`
|
|
* (see docs/RENEWAL_NOTICES.md's column-mapping table) so one template
|
|
* covers every carrier and tier instead of a clone per combination.
|
|
*
|
|
* `sentStatus` is read from `RenewalNotice` (schema.prisma) — the
|
|
* replacement for the legacy `CONTROL <ramo> RENEW[2/3] X MES` paper log
|
|
* — but this report is read-only; marking a notice as sent is a separate
|
|
* mutation (not yet built) that would upsert `RenewalNotice` by
|
|
* `[policyId, generation]`.
|
|
*/
|
|
const avisoRenovacion: ReportDef = {
|
|
slug: "aviso-renovacion",
|
|
title: "Aviso de renovación",
|
|
description:
|
|
"Cartas de aviso de renovación para pólizas por vencer en el mes y " +
|
|
"año seleccionados, con la generación de aviso (1a/2a/3a) y filtro " +
|
|
"opcional por aseguradora. Sustituye a los ~40 reportes clonados por " +
|
|
"aseguradora/cobertura de la legacy (ver docs/RENEWAL_NOTICES.md).",
|
|
domain: "polizas",
|
|
legacyName:
|
|
"AMPL R RENEW X MES NEW ATLAS 13 / RC RENEW X MES NEW ATLAS 13 / " +
|
|
"LIC RENEW X VENCE ATLAS 2013 / RCR RENEW X MES NEWATLAS 2013 (y " +
|
|
"sus clones por aseguradora y cobertura)",
|
|
format: "letter",
|
|
params: [
|
|
{
|
|
key: "policyType",
|
|
label: "Ramo",
|
|
kind: "select",
|
|
options: [
|
|
{ value: "AUTO", label: "Auto" },
|
|
{ value: "LICENCIAS", label: "Licencias" },
|
|
{ value: "INCENDIO", label: "Incendio" },
|
|
{ value: "MULT", label: "Multirriesgo" },
|
|
{ value: "M_EMPR", label: "M Empresarial" },
|
|
],
|
|
defaultValue: "AUTO",
|
|
},
|
|
{
|
|
key: "month",
|
|
label: "Mes de vencimiento (1-12)",
|
|
kind: "number",
|
|
defaultValue: String(new Date().getUTCMonth() + 1),
|
|
},
|
|
{
|
|
key: "year",
|
|
label: "Año de vencimiento",
|
|
kind: "number",
|
|
defaultValue: String(new Date().getUTCFullYear()),
|
|
},
|
|
{
|
|
key: "generation",
|
|
label: "Generación de aviso",
|
|
kind: "select",
|
|
options: [
|
|
{ value: "1", label: "1er aviso" },
|
|
{ value: "2", label: "2o aviso" },
|
|
{ value: "3", label: "3er aviso" },
|
|
],
|
|
defaultValue: "1",
|
|
},
|
|
{
|
|
key: "provider",
|
|
label: "Aseguradora (opcional)",
|
|
kind: "text",
|
|
placeholder: "Ej. ATLAS, QUALITAS…",
|
|
},
|
|
],
|
|
// Flat columns so CSV/XLSX/generic PDF exports stay useful even though
|
|
// the on-screen view renders each row as a full letter (LetterLayout in
|
|
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
|
|
columns: [
|
|
{ key: "policyNumber", label: "Póliza", type: "text" },
|
|
{ key: "customerName", label: "Cliente", type: "text" },
|
|
{ key: "provider", label: "Aseguradora", type: "text" },
|
|
{ key: "policyTo", label: "Vence", type: "date" },
|
|
{ key: "netPremium", label: "Prima neta", type: "money", align: "right" },
|
|
{ key: "total", label: "Total", type: "money", align: "right" },
|
|
{ key: "generation", label: "Generación", type: "number" },
|
|
{ key: "sentAt", label: "Enviado", type: "date" },
|
|
],
|
|
async run(prisma, p) {
|
|
const typeName = p.policyType ?? "AUTO";
|
|
const month = intParam(p, "month", new Date().getUTCMonth() + 1, 1, 12);
|
|
const year = intParam(p, "year", new Date().getUTCFullYear(), 1990, 2100);
|
|
const generation = intParam(p, "generation", 1, 1, 3);
|
|
const provider = p.provider?.trim();
|
|
|
|
const from = new Date(Date.UTC(year, month - 1, 1));
|
|
const to = new Date(Date.UTC(year, month, 1));
|
|
|
|
const rows = await prisma.policy.findMany({
|
|
where: {
|
|
policyType: { name: typeName },
|
|
archivedAt: null,
|
|
policyTo: { gte: from, lt: to },
|
|
...(provider
|
|
? { insuranceProvider: { name: { contains: provider } } }
|
|
: {}),
|
|
},
|
|
orderBy: { policyTo: "asc" },
|
|
select: {
|
|
id: true,
|
|
policyNumber: true,
|
|
policyTo: true,
|
|
netPremium: true,
|
|
policyFee: true,
|
|
total: true,
|
|
currency: true,
|
|
coveragesJson: true,
|
|
customer: { select: { name: true, nameMissing: true } },
|
|
insuranceProvider: { select: { name: true } },
|
|
vehicles: {
|
|
take: 1,
|
|
select: {
|
|
make: true,
|
|
model: true,
|
|
modelYear: true,
|
|
bodyType: true,
|
|
engineNumber: true,
|
|
licensePlate: true,
|
|
},
|
|
},
|
|
renewalNotices: {
|
|
where: { generation },
|
|
select: { sentAt: true, channel: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
let totalPremium = new Prisma.Decimal(0);
|
|
let sentCount = 0;
|
|
const out = rows.map((r) => {
|
|
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
|
const notice = r.renewalNotices[0];
|
|
if (notice?.sentAt) sentCount++;
|
|
// Legacy coverage columns not modeled as first-class Policy fields —
|
|
// see docs/RENEWAL_NOTICES.md's column-mapping table. Keys are best-
|
|
// effort (derived from the source schema, not yet verified against a
|
|
// live migrated DB) — confirm before relying on them in production.
|
|
const cov = (r.coveragesJson ?? {}) as Record<string, unknown>;
|
|
return {
|
|
__kind: "letter",
|
|
policyId: r.id,
|
|
policyNumber: r.policyNumber,
|
|
customerName: nameOf(r.customer),
|
|
provider: r.insuranceProvider?.name ?? "—",
|
|
policyTo: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—",
|
|
netPremium: r.netPremium ? r.netPremium.toFixed(2) : null,
|
|
policyFee: r.policyFee ? r.policyFee.toFixed(2) : null,
|
|
total: r.total ? r.total.toFixed(2) : null,
|
|
currency: r.currency,
|
|
coverageDays: cov.cobertura ?? null,
|
|
cslLimit: cov.csl_limite ?? null,
|
|
medicalCoverage: cov.gastos_medico ?? null,
|
|
propertyDamage: cov.propiedades ?? null,
|
|
perPersonLiability: cov.personas ?? null,
|
|
additionalService: cov.servicio_adicional ?? cov.servicio_adiconal ?? null,
|
|
vehicle: r.vehicles[0]
|
|
? {
|
|
make: r.vehicles[0].make,
|
|
model: r.vehicles[0].model,
|
|
modelYear: r.vehicles[0].modelYear,
|
|
bodyType: r.vehicles[0].bodyType,
|
|
engineNumber: r.vehicles[0].engineNumber,
|
|
licensePlate: r.vehicles[0].licensePlate,
|
|
}
|
|
: null,
|
|
generation,
|
|
sentAt: notice?.sentAt
|
|
? notice.sentAt.toISOString().slice(0, 10)
|
|
: null,
|
|
};
|
|
});
|
|
|
|
return {
|
|
rows: out,
|
|
totals: {
|
|
cartas: out.length,
|
|
enviadas: sentCount,
|
|
pendientes: out.length - sentCount,
|
|
primaTotal: totalPremium.toFixed(2),
|
|
},
|
|
subtitle: `Ramo: ${typeName} · vencen ${String(month).padStart(2, "0")}/${year} · generación ${generation}${
|
|
provider ? ` · aseguradora: ${provider}` : ""
|
|
} · ${out.length} avisos`,
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 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`,
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* REPORTE CHEQUE COUNT — everything captured against one check.
|
|
*
|
|
* The reconciliation half of the batch-capture flow (docs/RECEIPT_CAPTURE_SPEC
|
|
* §1.3): staff key many customers' receipts against one physical check, then
|
|
* check that what was captured adds up to what the check was cut for. Replaces
|
|
* `EDITA CHEQUE ALF/COUNT/NUM`, `REPORTE POR CHEQUE` and
|
|
* `REPORTE POR CHEQUE PARA ALFA` — four legacy objects, one parameterized
|
|
* report.
|
|
*
|
|
* Deliberately mirrors `BillingService.byCheck`'s rules rather than inventing
|
|
* its own: voided rows are dropped entirely, and outstanding (NOPAGO) rows are
|
|
* listed but excluded from the total, because the check never funded them.
|
|
*/
|
|
const chequeCount: ReportDef = {
|
|
slug: "cheque-count",
|
|
title: "Reporte por cheque",
|
|
description:
|
|
"Todos los movimientos capturados contra un mismo cheque, con el total " +
|
|
"para conciliar contra el importe físico del cheque. Los movimientos " +
|
|
"pendientes de pago (sin fondos) se listan pero no suman al total.",
|
|
domain: "estado-cuenta",
|
|
legacyName: "REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE COUNT",
|
|
format: "tabular",
|
|
params: [
|
|
{
|
|
key: "checkNumber",
|
|
label: "Número de cheque",
|
|
kind: "text",
|
|
placeholder: "Ej. 10432",
|
|
},
|
|
],
|
|
columns: [
|
|
{ key: "customerName", label: "Cliente", type: "text" },
|
|
{ key: "reference", label: "Referencia", type: "text" },
|
|
{ key: "period", label: "Periodo", type: "text" },
|
|
{ key: "concept", label: "Concepto", type: "text" },
|
|
{ key: "transactionDate", label: "Fecha", type: "date" },
|
|
{ key: "status", label: "Estado", type: "text" },
|
|
{ key: "amount", label: "Importe", type: "money", align: "right" },
|
|
],
|
|
async run(prisma, p) {
|
|
const checkNumber = p.checkNumber?.trim();
|
|
if (!checkNumber) {
|
|
return {
|
|
rows: [],
|
|
totals: { movimientos: 0 },
|
|
subtitle: "Indique un número de cheque",
|
|
};
|
|
}
|
|
|
|
const rows = await prisma.transaction.findMany({
|
|
where: { checkNumber, ...NOT_VOIDED },
|
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
|
select: {
|
|
transactionDate: true,
|
|
amount: true,
|
|
currency: true,
|
|
reference: true,
|
|
period: true,
|
|
outstanding: true,
|
|
type: { select: { nameEn: true, nameEs: true } },
|
|
customer: { select: { name: true, nameMissing: true } },
|
|
},
|
|
});
|
|
|
|
// Per currency, and never collapsed — same rule as the rest of the ledger.
|
|
const totals = new Map<string, Prisma.Decimal>();
|
|
let outstandingCount = 0;
|
|
for (const r of rows) {
|
|
if (r.outstanding) {
|
|
outstandingCount++;
|
|
continue;
|
|
}
|
|
totals.set(
|
|
r.currency,
|
|
(totals.get(r.currency) ?? new Prisma.Decimal(0)).plus(r.amount),
|
|
);
|
|
}
|
|
|
|
const totalsOut: Record<string, string | number> = {
|
|
movimientos: rows.length,
|
|
};
|
|
for (const [currency, sum] of totals) {
|
|
totalsOut[`total ${currency}`] = sum.toFixed(2);
|
|
}
|
|
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
|
|
|
|
return {
|
|
rows: rows.map((r) => ({
|
|
customerName: nameOf(r.customer),
|
|
reference: r.reference ?? "—",
|
|
period: r.period ?? "—",
|
|
concept: r.type?.nameEs || r.type?.nameEn || "Sin clasificar",
|
|
transactionDate: r.transactionDate.toISOString().slice(0, 10),
|
|
status: r.outstanding ? "Sin fondos" : "Pagado",
|
|
amount: r.amount.toFixed(2),
|
|
currency: r.currency,
|
|
})),
|
|
totals: totalsOut,
|
|
subtitle: `Cheque ${checkNumber} · ${rows.length} movimientos`,
|
|
};
|
|
},
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ export */
|
|
|
|
export const REPORTS: ReportDef[] = [
|
|
listadoEnRojo,
|
|
pagosNoEfectuados,
|
|
faltantes,
|
|
reporteDeEfectivo,
|
|
vigente,
|
|
avisoRenovacion,
|
|
edoCuentaDatos,
|
|
chequeCount,
|
|
];
|
|
|
|
export function findReport(slug: string): ReportDef | undefined {
|
|
return REPORTS.find((r) => r.slug === slug);
|
|
}
|