EFECTIVO is a journal, not a ledger. The office writes a numbered paper receipt for money handed over the counter and then posts that same receipt to the utilities ledger as reference `C<folio>`. Legacy summed the ledger alone — ledger_repository.php reads `datosfreak`, materialized from DATOS2 only — but the migration flattened both tables into one `transactions` table, so every balance counted each counter payment twice. Confirmed against the live legacy database rather than inferred: of the 297 receipts written in 2026, 296 carry a matching DATOS2 posting. Six of them post converted to pesos under a mistyped folio, which is why matching pairs on folio and amount found fewer duplicates than exist — and why this excludes the whole journal instead of a list of confirmed pairs. Only folio 13536 (CL 717, $400 USD) has no posting anywhere; that one wants a human. The database qualifier is load-bearing. `SEGUROS 16_be` keeps its own table also called EFECTIVO, and that one is the insurance line's only ledger — nothing posts it anywhere else. Excluding by table name alone would erase 55,444.95 USD and 63,957.78 MXN across 102 customers, 99 of whom have no other rows at all. Extending the qualified rule to the statement and the customer file also gives those 99 back a statement that is not empty. The same queries were missing the archive window the statement already had, so the worklist and the book also counted a closed year twice for customers floored inside an archive. Measured on production, utilities MXN: NUMid 6 and 173 unchanged to the cent, 501 unchanged at -10,874.33 (still the portal's number), 10 drops 2,362.20 -> -1,137.80 (exactly the 3,500.00 duplicate), 295 drops 14,377.46 -> 4,377.46 — which is what his statement already said. The worklist and the statement now agree, which is the point. Left alone deliberately: the movement browser, which is inventory rather than balance and should still show what was captured; and stats()'s outstanding rows, which turn on a client decision that is still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1112 lines
38 KiB
TypeScript
1112 lines
38 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 {
|
|
BALANCE_FORWARD_TYPE,
|
|
notCashJournal,
|
|
periodSourceTable,
|
|
} from "../billing/billing.service";
|
|
import {
|
|
intParam,
|
|
NOT_VOIDED,
|
|
parseDate,
|
|
type ReportDef,
|
|
} from "./reports.types";
|
|
import {
|
|
renewalLetterSelect,
|
|
toRenewalLetterRow,
|
|
} from "./renewal-letter";
|
|
|
|
/* ------------------------------------------------------------------ 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.
|
|
*/
|
|
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: renewalLetterSelect(generation),
|
|
});
|
|
|
|
let totalPremium = new Prisma.Decimal(0);
|
|
let sentCount = 0;
|
|
const out = rows.map((r) => {
|
|
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
|
const letter = toRenewalLetterRow(r, generation);
|
|
if (letter.sentAt) sentCount++;
|
|
return letter;
|
|
});
|
|
|
|
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 los movimientos del año en curso con saldo " +
|
|
"corrido, abriendo con el saldo anterior. El reporte del cliente final.",
|
|
domain: "estado-cuenta",
|
|
legacyName: "EDO CUENTA DATOS",
|
|
format: "statement",
|
|
params: [
|
|
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
|
|
// Which period to print. Blank means the year in progress; an earlier year
|
|
// prints from its imported archive, the same source the on-screen
|
|
// statement reads.
|
|
{
|
|
key: "year",
|
|
label: "Periodo (año)",
|
|
kind: "number",
|
|
placeholder: "año en curso",
|
|
},
|
|
],
|
|
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" };
|
|
|
|
// The source-table exclusion, the balance floor and the year scope below
|
|
// are BillingService.statement's, because this report and
|
|
// /estado-cuenta/[id] are the same statement — one printable, one on
|
|
// screen — and a customer holding both must not read two balances.
|
|
const floor = await prisma.transaction.findFirst({
|
|
where: {
|
|
customerId,
|
|
voidedAt: null,
|
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
|
},
|
|
orderBy: { transactionDate: "desc" },
|
|
select: { transactionDate: true },
|
|
});
|
|
|
|
// Which period to print. An earlier year comes from its imported archive,
|
|
// tagged rather than dated, exactly as the on-screen statement reads it.
|
|
const thisYear = new Date().getUTCFullYear();
|
|
const askedYear = Number(p.year);
|
|
const requestedYear =
|
|
Number.isInteger(askedYear) && askedYear > 0 ? askedYear : thisYear;
|
|
const isArchive = requestedYear !== thisYear;
|
|
|
|
const rows = await prisma.transaction.findMany({
|
|
where: {
|
|
customerId,
|
|
voidedAt: null,
|
|
...(isArchive
|
|
? // The archive is one period's ledger already, so the tag is the
|
|
// whole filter and the balance floor must not apply — the floor
|
|
// hides exactly the history this period is asking for.
|
|
{ legacySourceTable: periodSourceTable(requestedYear) }
|
|
: {
|
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
|
// Archive rows count as history below the year start (that is
|
|
// what `opening` is for, and for a customer floored by an archive
|
|
// it is the only carry there is) and are dropped at or above it.
|
|
// Same rule as the on-screen twin — see BillingService.statement.
|
|
AND: [
|
|
{
|
|
OR: [
|
|
{ legacySourceTable: null },
|
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
|
{
|
|
transactionDate: {
|
|
lt: new Date(Date.UTC(requestedYear, 0, 1)),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
// The cash receipt book, which the ledger already carries as
|
|
// its own `C<folio>` postings. Taken from the shared helper
|
|
// rather than restated, so the printed statement and the screen
|
|
// cannot drift apart — and so this keeps the database
|
|
// qualifier that spares the insurance line's own EFECTIVO.
|
|
notCashJournal(),
|
|
],
|
|
}),
|
|
},
|
|
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 } },
|
|
},
|
|
});
|
|
|
|
// Scoped to the calendar year and listed oldest-first, the way the legacy
|
|
// EDO CUENTA sheet reads. Rows from earlier years still move the running
|
|
// balance — they are folded into `opening` and printed as a single "saldo
|
|
// anterior" line, which is what a BALANCE FORWARD row is.
|
|
// An archive needs no fold: it *is* the period, and its own Jan-1 BALANCE
|
|
// FORWARD row is the carry, printed like legacy printed it.
|
|
const yearStart = isArchive
|
|
? new Date(0)
|
|
: new Date(Date.UTC(requestedYear, 0, 1));
|
|
const year = requestedYear;
|
|
|
|
const running = new Map<string, Prisma.Decimal>();
|
|
const opening = new Map<string, Prisma.Decimal>();
|
|
const visible: typeof rows = [];
|
|
|
|
const movements = rows.flatMap((r) => {
|
|
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
|
const next = prev.plus(r.amount);
|
|
running.set(r.currency, next);
|
|
|
|
if (r.transactionDate < yearStart) {
|
|
opening.set(r.currency, next);
|
|
return [];
|
|
}
|
|
|
|
visible.push(r);
|
|
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),
|
|
};
|
|
});
|
|
|
|
// The carried balance, printed as the statement's first line — same shape
|
|
// as a movement row so it needs nothing special from the renderer.
|
|
const carried = [...opening.entries()]
|
|
.filter(([, amount]) => !amount.isZero())
|
|
.map(([currency, amount]) => ({
|
|
date: yearStart.toISOString().slice(0, 10),
|
|
domain: "UTILITY",
|
|
currency,
|
|
reference: "",
|
|
period: `Al cierre de ${year - 1}`,
|
|
checkNumber: "",
|
|
concept: "SALDO ANTERIOR",
|
|
amount: amount.toFixed(2),
|
|
balanceAfter: amount.toFixed(2),
|
|
}));
|
|
|
|
// Per-currency summary, seeded with the carried balance so it reconciles
|
|
// against the last running balance printed below.
|
|
const perCurrency = new Map<
|
|
string,
|
|
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
|
|
>();
|
|
for (const [currency, amount] of opening) {
|
|
perCurrency.set(currency, {
|
|
currency,
|
|
charges: amount.lessThan(0) ? amount : new Prisma.Decimal(0),
|
|
credits: amount.lessThan(0) ? new Prisma.Decimal(0) : amount,
|
|
count: 0,
|
|
});
|
|
}
|
|
for (const r of visible) {
|
|
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" },
|
|
...carried,
|
|
...movements,
|
|
],
|
|
subtitle: `${nameOf(customer)} · ${year} · ${visible.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);
|
|
}
|