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 =
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Insurance Renewal Notices ("Atlas" reports)
|
||||
|
||||
Staff refer to this report in the UI as "the Atlas report", but **Atlas
|
||||
isn't a report — it's a carrier**: `ATLAS, S.A.` is one of the insurance
|
||||
companies (`COMP` column) SEGUROS brokers policies for, alongside
|
||||
`QUALITAS, S.A.` and others. The legacy frontend (`SEGUROS 16.mdb`) never
|
||||
parameterized carrier or coverage tier in its renewal-notice report — it
|
||||
cloned the entire report + query chain once per carrier per coverage
|
||||
variant instead. This doc explains that clone pattern and the underlying
|
||||
workflow so the new platform can replace ~40 cloned Access objects with
|
||||
one parameterized feature.
|
||||
|
||||
## Why this needed extra tooling
|
||||
|
||||
`objects.json`/`LEGACY_DATABASES_OBJECTS.md` (see `migration/catalog_objects.py`)
|
||||
only capture report *names* — DAO's catalog interface doesn't expose a
|
||||
report's `RecordSource` or control layout, only the full Access object
|
||||
model does, and that model refused to load here
|
||||
(`"The Visual Basic for Applications project in the database is corrupt"`,
|
||||
a common failure mode for old .mdb files opened in a newer Access build).
|
||||
|
||||
The workaround: `Application.SaveAsText(acReport, name, path)` exports a
|
||||
report's complete design as plain text without touching the VBA project.
|
||||
The raw (binary-blob-stripped) exports for the ATLAS renewal reports are
|
||||
committed in [`migration/legacy_report_defs/`](../migration/legacy_report_defs/):
|
||||
|
||||
- `AMPL_R_RENEW_X_MES_NEW_ATLAS_13.txt` — Auto/Amplia (full coverage)
|
||||
- `AMPL_RENEW_X_MES_NEW_ATLAS_2013.txt` — Auto/Amplia, alternate batch
|
||||
- `RC_RENEW_X_MES_NEW_ATLAS_13.txt` — Auto/RC (liability only)
|
||||
- `RCR_RENEW_X_MES_NEWATLAS_2013.txt` — Auto/RC, renewal-of-renewal variant
|
||||
- `LIC_RENEW_X_VENCE_ATLAS_2013.txt` — Driver's-license insurance
|
||||
|
||||
(`PrtDevMode`/`PrtMip`/`OleData`/`GUID` binary properties — printer
|
||||
settings and object GUIDs, no business meaning — were stripped so the
|
||||
files are readable text instead of multi-hundred-KB hex dumps.)
|
||||
|
||||
## The report chain
|
||||
|
||||
Each report is bound to a query that layers 2–3 other queries, filtered to
|
||||
one carrier, with two typed parameters staff fill in every run:
|
||||
|
||||
```
|
||||
Report: AMPL R RENEW X MES NEW ATLAS 13
|
||||
RecordSource -> Query: AMPL R RENEW CALC ATLAS 13
|
||||
FROM [AMPL R CALC VIG], [AMPL R MENS] (in-force calc view + installment schedule)
|
||||
WHERE COMP = "ATLAS, S.A."
|
||||
AND DatePart("m",[HASTA]) = [TECLEE MES DE VENCIMIENTO (1 A 12)] -- typed param
|
||||
AND DatePart("yyyy",[HASTA]) = [TECLEE AÑO DE VENCIMIENTO (1999)] -- typed param
|
||||
```
|
||||
|
||||
```
|
||||
Report: LIC RENEW X VENCE ATLAS 2013
|
||||
RecordSource -> Query of the SAME NAME (query and report share a name)
|
||||
FROM [LIC MENS], LIC INNER JOIN DATGRAL ... INNER JOIN [VIGENT CASA] ...
|
||||
WHERE DatePart("m",[hasta]) = [TECLEE MES DE VENCIMIENTO 1 A 12]
|
||||
AND DatePart("yyyy",[hasta]) = [TECLE AÑO VENCIMIENTO (1999)]
|
||||
AND LIC.COMP = "ATLAS, S.A."
|
||||
```
|
||||
|
||||
Staff pick a line of business, type the expiry month + year, and the
|
||||
report prints one notice per matching policy for that carrier that month.
|
||||
On screen the report is captioned **"AVISO DE RENOVACION"** (auto lines)
|
||||
or **"R E N E W A L N O T I C E"** (license-insurance line). Every page
|
||||
prints the notice **twice** (identical top-half/bottom-half sections) —
|
||||
one copy to mail, one for the office file.
|
||||
|
||||
## The multi-notice (reminder) workflow
|
||||
|
||||
Renewal reminders escalate through **three generations**, each its own
|
||||
report clone, with a matching `CONTROL ...` companion report (a
|
||||
send/checklist log):
|
||||
|
||||
| Generation | Report suffix | Control/log report |
|
||||
|---|---|---|
|
||||
| 1st notice | `RENEW` / (bare) | `CONTROL <LOB> RENEW X MES` |
|
||||
| 2nd notice | `RENEW2` | `CONTROL <LOB> RENEW2 X MES` (or `X MES` sibling) |
|
||||
| 3rd notice | `RENEW3` | `CONTROL <LOB> RENEW3 X MES` |
|
||||
|
||||
This pattern repeats per line of business: `AMPL`/`AMPL R` (auto full
|
||||
coverage), `RC`/`RC R` (auto liability), `LIC` (driver's license), `RCR`,
|
||||
`MF`/`MF2`/`MF3` (home/multi-risk), `MCA2`, `ME`, `INCEN` (fire) — none of
|
||||
it is visible from the table schema alone, only from the report/query
|
||||
names (see `docs/LEGACY_DATABASES_OBJECTS.md`, "What the Reports actually
|
||||
reveal").
|
||||
|
||||
## What's hardcoded vs. what's real policy data
|
||||
|
||||
The extracted designs show the letter body mixes two very different kinds
|
||||
of content:
|
||||
|
||||
1. **Per-policy data**, pulled live from the query: customer id, policy
|
||||
number, vehicle (make/model/body/engine), expiry date.
|
||||
2. **Static label text baked into the report design**, re-typed by hand
|
||||
every time a batch was cloned for a new rate or carrier — e.g. (from
|
||||
`AMPL_R_RENEW_X_MES_NEW_ATLAS_13.txt`):
|
||||
- `"COLLISION DEDUCTIBLE $ 500.00 Dls. THEFT DEDUCTIBLE $ 1000.00 Dls. ..."`
|
||||
- `"New Renewal annual Premium $ 365.25 Dls."`
|
||||
- `"Total Annual Premium $ 405.25 Dls"`
|
||||
- the whole CSL/medical-coverage recommendation and rental-car upsell
|
||||
paragraphs
|
||||
|
||||
None of those dollar figures are formulas — they're literal text, which is
|
||||
*why* there are so many near-duplicate reports: a new coverage tier or
|
||||
rate meant cloning the whole report and hand-editing the labels, rather
|
||||
than changing a parameter.
|
||||
|
||||
The underlying **data these figures should come from already exists** on
|
||||
the source tables and is preserved (unmapped-but-captured) in
|
||||
`Policy.coveragesJson` after migration — confirmed against
|
||||
`docs/LEGACY_DATABASES.md`'s table appendix:
|
||||
|
||||
| Legacy column | Sanitized `coveragesJson` key | Meaning |
|
||||
|---|---|---|
|
||||
| `COBERTURA` | `cobertura` | Coverage days/territory tier (30/40/50/365) |
|
||||
| `CSL LIMITE` | `csl_limite` | Combined single limit (liability) |
|
||||
| `GASTOS MEDICO` | `gastos_medico` | Medical coverage amount |
|
||||
| `SERVICIO ADICIONAL` | `servicio_adicional` (LICENCIAS: `servicio_adiconal`, a source typo) | Add-on service flag |
|
||||
| `PROPIEDADES` | `propiedades` | Property-damage coverage amount |
|
||||
| `PERSONAS` | `personas` | Per-person liability amount |
|
||||
|
||||
(`Policy.netPremium`/`total`/`currency` are already first-class columns —
|
||||
see `packages/database/prisma/schema.prisma`.)
|
||||
|
||||
**Migration implication:** a rebuilt renewal notice should render these
|
||||
from data (one parameterized template), not from report design text. See
|
||||
`RenewalNotice` in `schema.prisma` and the `aviso-renovacion` entry in
|
||||
`apps/api/src/reports/reports.registry.ts` for the first cut at this.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Only the ATLAS variants were extracted verbatim; the QUALITAS and
|
||||
"generic" (no-carrier-suffix) clones weren't pulled but are presumed
|
||||
structurally identical modulo the `COMP` filter and hardcoded figures.
|
||||
- `coveragesJson` key names above are derived from
|
||||
`migration/extract.py`'s `sanitize_column_name` (lowercase,
|
||||
non-alphanumeric → `_`) applied to the *source* column names in
|
||||
`docs/LEGACY_DATABASES.md`, not verified against a live migrated
|
||||
database (no staged output was present in this environment). Confirm
|
||||
against real data before wiring a template to these keys.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -181,12 +181,42 @@ model Policy {
|
||||
claims Claim[]
|
||||
documents PolicyDocument[]
|
||||
properties Property[]
|
||||
renewalNotices RenewalNotice[]
|
||||
|
||||
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
||||
@@index([policyNumber])
|
||||
@@map("policies")
|
||||
}
|
||||
|
||||
enum RenewalNoticeChannel {
|
||||
MAIL
|
||||
EMAIL
|
||||
}
|
||||
|
||||
/// Replaces the legacy `CONTROL <ramo> RENEW[2/3] X MES` reports — a
|
||||
/// per-batch printed checklist of who'd been sent which reminder. One row
|
||||
/// per notice generation actually sent for a policy, so "who got a 1st/2nd/
|
||||
/// 3rd notice and when" is a query instead of a paper trail. See
|
||||
/// docs/RENEWAL_NOTICES.md for the legacy report chain this replaces.
|
||||
model RenewalNotice {
|
||||
id String @id @default(uuid())
|
||||
policyId String
|
||||
policy Policy @relation(fields: [policyId], references: [id])
|
||||
// 1 = first notice (bare RENEW), 2 = RENEW2, 3 = RENEW3 in the legacy naming.
|
||||
generation Int
|
||||
channel RenewalNoticeChannel @default(MAIL)
|
||||
sentAt DateTime?
|
||||
sentById String?
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// One row per generation per policy — matches the legacy's 1st/2nd/3rd
|
||||
// notice cadence; re-running the same generation for a policy updates it
|
||||
// rather than duplicating a log entry.
|
||||
@@unique([policyId, generation])
|
||||
@@map("renewal_notices")
|
||||
}
|
||||
|
||||
/// Unpivots the 4 hardcoded payment-installment columns found on every
|
||||
/// legacy policy table (1ER PAGO/FECHA PAGO/NO CHEQUE, ...2, ...3, ...4).
|
||||
model PolicyPaymentInstallment {
|
||||
|
||||
Reference in New Issue
Block a user