feat(reports): parameterized renewal-notice report + legacy report reference
Build and Push Images / Build jorgecuadros-web (push) Failing after 59s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m59s

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>
This commit is contained in:
2026-07-25 23:05:21 -07:00
co-authored by Claude Sonnet 5
parent 1b79b43a54
commit f7ae0d5342
12 changed files with 8358 additions and 3 deletions
+203
View File
@@ -598,6 +598,208 @@ const vigente: ReportDef = {
},
};
/**
* 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
@@ -755,6 +957,7 @@ export const REPORTS: ReportDef[] = [
faltantes,
reporteDeEfectivo,
vigente,
avisoRenovacion,
edoCuentaDatos,
];
+4 -2
View File
@@ -26,8 +26,10 @@ export type ReportDomain =
| "estado-cuenta"
| "chequera";
/** How the runner should render rows: a grid, or a per-customer statement. */
export type ReportFormat = "tabular" | "statement";
/** How the runner should render rows: a grid, a per-customer statement, or
* one printable letter per row (e.g. renewal notices — see `format:
* "letter"` reports for the `__kind: "letter"` row shape they emit). */
export type ReportFormat = "tabular" | "statement" | "letter";
/** Filter controls the report's UI should render. */
export type ParamDef =
+78
View File
@@ -2660,6 +2660,84 @@ button {
margin: 0 0 8px;
}
/* Renewal notices (aviso-renovacion) — one card per policy due, see
docs/RENEWAL_NOTICES.md for the legacy report this replaces. */
.renewal-letters {
display: flex;
flex-direction: column;
gap: 20px;
}
.renewal-letter {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px 20px;
break-inside: avoid;
}
.renewal-letter-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid var(--line);
padding-bottom: 10px;
margin-bottom: 12px;
}
.renewal-letter-title {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
color: var(--ink);
margin: 0;
}
.renewal-letter-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 12px;
}
.renewal-letter-vehicle {
background: var(--paper-2);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 12px;
}
.renewal-letter-coverage {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.renewal-letter-coverage-item {
border-left: 2px solid var(--line-strong);
padding-left: 10px;
}
.renewal-letter-value {
font-size: 14px;
font-weight: 600;
color: var(--ink);
margin: 2px 0 0;
}
.renewal-letter-premium {
display: flex;
gap: 20px;
font-size: 14px;
border-top: 1px solid var(--line);
padding-top: 10px;
}
.renewal-letter-total {
font-weight: 700;
color: var(--brand-800);
}
@media print {
.renewal-letter {
page-break-inside: avoid;
page-break-after: always;
}
.renewal-letter:last-child {
page-break-after: auto;
}
}
/* Context buttons (the inline shortcut on existing pages) */
.context-reports {
display: flex;
+159
View File
@@ -267,6 +267,8 @@ function ResultBlock({
{def.format === "statement" ? (
<StatementLayout result={result} />
) : def.format === "letter" ? (
<LetterLayout result={result} />
) : (
<TabularLayout result={result} />
)}
@@ -472,5 +474,162 @@ function StatementLayout({ result }: { result: ReportRunResult }) {
);
}
/* ----------------------------------------------------------- letter layout */
const GENERATION_LABEL: Record<string, string> = {
"1": "1er aviso",
"2": "2o aviso",
"3": "3er aviso",
};
interface LetterVehicle {
make?: string | null;
model?: string | null;
modelYear?: string | number | null;
bodyType?: string | null;
engineNumber?: string | null;
licensePlate?: string | null;
}
/**
* One card per policy due for renewal — the parameterized replacement for
* the ~40 cloned "AVISO DE RENOVACION" Access reports (see
* docs/RENEWAL_NOTICES.md). Coverage figures (CSL limit, medical coverage,
* etc.) come from data (`aviso-renovacion`'s ReportDef reads
* `Policy.coveragesJson`) instead of the legacy's hand-typed label text, so
* one layout renders every carrier/coverage combination.
*/
function LetterLayout({ result }: { result: ReportRunResult }) {
const letters = result.rows.filter((r) => r.__kind === "letter");
if (letters.length === 0) {
return (
<div className="empty-inline">
No hay pólizas por vencer con los filtros actuales.
</div>
);
}
return (
<div className="renewal-letters">
{letters.map((r, i) => {
const vehicle = r.vehicle as LetterVehicle | null;
const generation = String(r.generation ?? "1");
return (
<article className="renewal-letter" key={String(r.policyId ?? i)}>
<header className="renewal-letter-head">
<div>
<h2 className="renewal-letter-title">Aviso de renovación</h2>
<p className="muted small">
{GENERATION_LABEL[generation] ?? `Aviso ${generation}`}
</p>
</div>
<div className="renewal-letter-status">
{r.sentAt ? (
<span className="badge badge-positive">
<span className="dot" />
Enviado {String(r.sentAt)}
</span>
) : (
<span className="badge badge-neutral">
<span className="dot" />
Pendiente
</span>
)}
</div>
</header>
<div className="renewal-letter-grid">
<div>
<p className="muted small">Cliente</p>
<p className="renewal-letter-value">
{String(r.customerName ?? "—")}
</p>
</div>
<div>
<p className="muted small">Póliza</p>
<p className="renewal-letter-value">
{String(r.policyNumber ?? "—")}
</p>
</div>
<div>
<p className="muted small">Aseguradora</p>
<p className="renewal-letter-value">
{String(r.provider ?? "—")}
</p>
</div>
<div>
<p className="muted small">Vence</p>
<p className="renewal-letter-value">
{String(r.policyTo ?? "—")}
</p>
</div>
</div>
{vehicle && (
<div className="renewal-letter-vehicle">
<p className="muted small">Vehículo asegurado</p>
<p className="renewal-letter-value">
{[vehicle.modelYear, vehicle.make, vehicle.model]
.filter(Boolean)
.join(" ")}
{vehicle.bodyType ? ` · ${vehicle.bodyType}` : ""}
</p>
<p className="muted small">
{[
vehicle.engineNumber && `Motor: ${vehicle.engineNumber}`,
vehicle.licensePlate && `Placa: ${vehicle.licensePlate}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
)}
<div className="renewal-letter-coverage">
<CoverageItem label="Cobertura (días)" value={r.coverageDays} />
<CoverageItem label="CSL" value={r.cslLimit} money />
<CoverageItem label="Gastos médicos" value={r.medicalCoverage} money />
<CoverageItem label="Daños a propiedad" value={r.propertyDamage} money />
<CoverageItem label="Responsabilidad por persona" value={r.perPersonLiability} money />
</div>
<div className="renewal-letter-premium">
{r.netPremium && (
<span>Prima neta: {formatCell(r.netPremium, "money")}</span>
)}
{r.total && (
<span className="renewal-letter-total">
Total: {formatCell(r.total, "money")} {String(r.currency ?? "")}
</span>
)}
</div>
</article>
);
})}
</div>
);
}
function CoverageItem({
label,
value,
money,
}: {
label: string;
value: unknown;
money?: boolean;
}) {
if (value === null || value === undefined || value === "") return null;
return (
<div className="renewal-letter-coverage-item">
<p className="muted small">{label}</p>
<p className="renewal-letter-value">
{money ? formatCell(value, "money") : String(value)}
</p>
</div>
);
}
// Hint to the bundler that API_ORIGIN is part of the API surface used here.
void API_ORIGIN;
+1 -1
View File
@@ -1037,7 +1037,7 @@ export type ReportDomain =
| "estado-cuenta"
| "chequera";
export type ReportFormat = "tabular" | "statement";
export type ReportFormat = "tabular" | "statement" | "letter";
/** Param declaration a report exposes to its filter form. */
export type ReportParam =