Replaces ~40 legacy Access renewal-notice report clones (one per carrier per coverage tier, e.g. AMPL/RC/LIC RENEW X MES/VENCE ATLAS 13/2013) with one parameterized aviso-renovacion report driven by real Policy/Vehicle/ coveragesJson data instead of hand-typed label text per clone. - schema.prisma: add RenewalNotice, replacing the legacy CONTROL <ramo> RENEW[2/3] X MES paper log of which notice generation was sent - reports: new "letter" ReportFormat + aviso-renovacion registry entry + LetterLayout renderer in ReportRunner.tsx - docs/RENEWAL_NOTICES.md + migration/legacy_report_defs/: extracted (via Application.SaveAsText, since the VBA project wouldn't load) and documented the legacy report/query chain this replaces Coveragesjson key names and a mark-as-sent mutation are still unverified/ unbuilt — see caveats in docs/RENEWAL_NOTICES.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
967 lines
32 KiB
TypeScript
967 lines
32 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`,
|
|
};
|
|
},
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ export */
|
|
|
|
export const REPORTS: ReportDef[] = [
|
|
listadoEnRojo,
|
|
pagosNoEfectuados,
|
|
faltantes,
|
|
reporteDeEfectivo,
|
|
vigente,
|
|
avisoRenovacion,
|
|
edoCuentaDatos,
|
|
];
|
|
|
|
export function findReport(slug: string): ReportDef | undefined {
|
|
return REPORTS.find((r) => r.slug === slug);
|
|
}
|