feat(reports): parameterized renewal-notice report + legacy report reference
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:
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user