feat(policy-ocr): read A.N.A. Seguros' two policy faces
A.N.A. is the Rosarito office's tourist auto book and the second carrier
the policy OCR pipeline reads. It ships two unrelated faces, and the split
is different from GMX's: GMX ships two documents about one policy, A.N.A.
ships two products.
AUTOMOBILE (SPECIAL POLICY FOR TOURISTS) insures a car; vehicle table,
9 numbered sections, one
LIMIT OF LIABILITY column
DRIVER'S POLICY (the office: "licencia") insures up to 5 named drivers;
no vehicle at all, 6 unnumbered
sections in a different order,
SUM INSURED + PREMIUM columns
The four automobile products the office sells (amplia / responsabilidad
civil, annual / by-the-day) are the same layout with different numbers, so
they get one parser rather than four.
These are born-digital portal PDFs, so pdftotext -layout returns exact
columns and the driver's-policy parser uses that: its two value columns
print the same shape (100,000.00 usd. / 18.70 usd.) with no per-row label,
so horizontal position is the only thing separating them. The split comes
from the header's own offsets, not a constant, because they shift between
products; when it can't be read every amount is reported as a sum insured
and the reviewer is told, rather than half the premiums being filed as
coverage limits.
Three things the layout will punish a naive read for:
- Each PDF prints its face two or three times (ORIGINAL, AGENT COPY, then
a receipt and three travel cards) and the pipeline concatenates every
page before parsing. The coverage walk is bounded to the first copy and
the driver list to the first POLICY HOLDER block. Unbounded, the licencia
returns the same person three times, which reads as a three-driver policy
rather than as a bug.
- The money row is read positionally off its header. An unused DISCOUNT
prints as a bare "-", so "find the six amounts" shifts every value one
column left on a discounted policy.
- Two five-digit numbers sit in the header band and only one is the agent
clave; the agent's street address is "BENITO JUAREZ 25 No.50 INT 38",
three lines above the No. cell holding the policy number.
Sections 6-8 print a PREMIUM where the others print a limit, so
ParsedCoverage gains an optional `premium` (GMX never fills it) and the
review table a column: $40 is what legal aid cost, not a $40 liability
limit. Exclusions follow the GMX rule and go in the risk label with a null
amount -- which matters more here, since a responsabilidad-civil policy
prints 0.00 for material damage and the two are identical on the page.
Also in this change:
- coveragePeriodDays is parsed and written. A.N.A. sells 3- and 4-day
policies; Policy.coveragePeriodDays defaults to 365, so a weekend policy
left at the default sits in the renewals window a year out. Derived from
the dates, cross-checked against the printed DAYS cell, disagreement
noted not resolved.
- Vehicles and named drivers are parsed, shown read-only in review, and
written as Vehicle / InsuredDriver rows on confirm, skipping any already
on the policy (VIN then plate; licence then name). The case that forces
the skip is confirming a renewal onto an existing policy. Nothing is ever
updated or deleted -- a changed plate lands as a second row for a human.
- Batch.provider is set from what the parsers actually claimed instead of
being hardcoded "GMX", so a mixed upload is labelled as mixed and the
header can never contradict its own documents. PolicyDocument.documentType
follows the same rule (was hardcoded GMX_POLICY).
- matchNote becomes TEXT. It was VARCHAR(191) and the note trail was sliced
to 190 chars, which cut the tail notes -- the "could not read X" ones.
- The policy detail page renders an array coveragesJson as a table. Both
shapes have always been possible there, but the object renderer was the
only one, so an OCR-confirmed policy showed a row per array index labelled
"0", "1", "2" with [object Object] as the value. ANA makes that routine.
GMX is untouched behaviourally; its two parsers now spread a shared empty
base instead of listing every null field. 29 new parser cases against
verbatim pdftotext output of three real ANA PDFs, 53 in the suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -648,10 +648,79 @@ function SiniestrosSection({ data }: { data: PolicyDetail }) {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Coberturas */
|
||||
/** The legacy tables carry per-line coverage columns the target schema does
|
||||
* not model; the migration preserved them verbatim in `coveragesJson`. */
|
||||
/**
|
||||
* `coveragesJson` holds two unrelated shapes and the section renders each on
|
||||
* its own terms:
|
||||
*
|
||||
* - **A Spanish-keyed object** — the legacy per-line coverage columns the
|
||||
* target schema does not model, preserved verbatim by the migration. Every
|
||||
* policy imported from Access carries this one.
|
||||
* - **A `ParsedCoverage[]` array** — written by the policy OCR confirm step
|
||||
* (GMX's coverage table, ANA's numbered risk sections).
|
||||
*
|
||||
* Running the object renderer over the array is what used to happen, and it
|
||||
* produced a row per array index labelled "0", "1", "2" with `[object
|
||||
* Object]` as its value — not a crash, so nothing surfaced it.
|
||||
*/
|
||||
interface StoredCoverage {
|
||||
risk?: string;
|
||||
insuredAmount?: number | null;
|
||||
deductible?: string | null;
|
||||
lossParticipation?: string | null;
|
||||
premium?: number | null;
|
||||
}
|
||||
|
||||
function CoberturasSection({ data }: { data: PolicyDetail }) {
|
||||
const entries = Object.entries(data.coveragesJson ?? {}).filter(
|
||||
const raw = data.coveragesJson ?? null;
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
const rows = (raw as StoredCoverage[]).filter((c) => c && c.risk);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="seguros" title="Coberturas" count={rows.length} />
|
||||
<div className="card">
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Riesgo</th>
|
||||
<th className="num">Suma asegurada</th>
|
||||
<th className="num">Prima</th>
|
||||
<th>Deducible</th>
|
||||
<th>Participación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((c, i) => (
|
||||
<tr key={i}>
|
||||
<td>{c.risk}</td>
|
||||
<td className="num">
|
||||
{c.insuredAmount == null
|
||||
? "—"
|
||||
: formatMoney(c.insuredAmount.toString(), data.currency)}
|
||||
</td>
|
||||
<td className="num">
|
||||
{c.premium == null
|
||||
? "—"
|
||||
: formatMoney(c.premium.toString(), data.currency)}
|
||||
</td>
|
||||
<td>{c.deductible ?? "—"}</td>
|
||||
<td>{c.lossParticipation ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||
Coberturas leídas del PDF de la aseguradora.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(raw ?? {}).filter(
|
||||
([, v]) => v !== null && v !== "" && v !== 0,
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AppShell } from "@/components/AppShell";
|
||||
import { PolicyCaptura } from "@/components/PolicyCaptura";
|
||||
|
||||
/**
|
||||
* OCR mode of the policy intake screen. Drops the GMX PDF, walks through
|
||||
* OCR mode of the policy intake screen. Drops the GMX or A.N.A. PDF, walks through
|
||||
* per-page review, confirms. Same wrapper as `/polizas/nuevo` (manual)
|
||||
* with `initialMode="auto"`, so the tab strip is identical and swapping
|
||||
* modes doesn't drop state.
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useCan } from "@/lib/abilities";
|
||||
* two ways in:
|
||||
*
|
||||
* - **manual** — `PolicyForm` keys every field by hand.
|
||||
* - **auto** — `PolicyOcrIntake` uploads a GMX PDF, OCR proposes the
|
||||
* - **auto** — `PolicyOcrIntake` uploads a GMX or A.N.A. PDF, OCR proposes the
|
||||
* policy, a human still confirms.
|
||||
*
|
||||
* Both end at the same place (a `Policy` row on a customer's file) so they
|
||||
@@ -28,7 +28,7 @@ export type PolicyCaptureMode = "manual" | "auto";
|
||||
const MODE_HINT: Record<PolicyCaptureMode, string> = {
|
||||
manual:
|
||||
"Captura cada campo a mano. Use esta opción cuando la póliza llega en papel, en un correo sin PDF legible, o cuando hay que revisar cada dato.",
|
||||
auto: "Suelte el PDF descargado del portal de GMX y el sistema propondrá los campos. Nada se registra sin tu confirmación.",
|
||||
auto: "Suelte el PDF descargado del portal de GMX o de A.N.A. y el sistema propondrá los campos. Nada se registra sin tu confirmación.",
|
||||
};
|
||||
|
||||
export function PolicyCaptura({ initialMode = "manual" }: { initialMode?: PolicyCaptureMode }) {
|
||||
|
||||
@@ -13,9 +13,12 @@ import type { PolicyOcrBatch, PolicyOcrBatchStatus } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Insurance OCR intake — mirror of StatementIntake, scoped to the insurance
|
||||
* side. Today the only provider is GMX; the parser dispatches on a brand
|
||||
* wordmark (`Grupo Mexicano de Seguros` / `gmx.com.mx` / the GMX letterhead)
|
||||
* and a new portal only needs a new BRAND entry plus a parser file.
|
||||
* side. GMX and A.N.A. today; the parser dispatches on a brand wordmark
|
||||
* (`Grupo Mexicano de Seguros` / `gmx.com.mx`, `A.N.A. Compañía de Seguros` /
|
||||
* `anaseguros.com.mx`) and a new portal only needs a new BRAND entry plus a
|
||||
* parser file. The uploader is never asked which provider a file came from —
|
||||
* a batch may mix them, and the pipeline labels the batch from what the
|
||||
* parsers actually claimed.
|
||||
*
|
||||
* Lives inside the `Pólizas` page rather than a top-level route because it
|
||||
* is one mode of one job (staff uploading whatever PDFs the office has on
|
||||
@@ -102,8 +105,8 @@ export function PolicyOcrIntake() {
|
||||
<div className="state-box">Cargando…</div>
|
||||
) : batches.length === 0 ? (
|
||||
<div className="state-box">
|
||||
Todavía no hay lotes de pólizas. Descargue el certificado del portal
|
||||
de GMX y suéltelo arriba.
|
||||
Todavía no hay lotes de pólizas. Descargue la póliza del portal de
|
||||
GMX o de A.N.A. y suéltela arriba.
|
||||
</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
@@ -183,14 +186,14 @@ function UploadCard({ onDone }: { onDone: () => void }) {
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Subir PDFs de pólizas (GMX)
|
||||
Subir PDFs de pólizas (GMX / A.N.A.)
|
||||
</h2>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Referencia (opcional)</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="ej. GMX julio 2026"
|
||||
placeholder="ej. ANA agosto 2026"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -274,6 +274,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
netPremium: doc.extractedNetPremium ?? "",
|
||||
total: doc.extractedTotal ?? "",
|
||||
premiumPayment: doc.extractedPremiumPayment ?? "",
|
||||
coveragePeriodDays: doc.extractedCoveragePeriodDays?.toString() ?? "",
|
||||
postPremium: doc.extractedNetPremium != null && Number(doc.extractedNetPremium) > 0,
|
||||
});
|
||||
const [customerId, setCustomerId] = useState(
|
||||
@@ -311,6 +312,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
netPremium: numOrUndef(v.netPremium),
|
||||
total: numOrUndef(v.total),
|
||||
premiumPayment: trimOrUndef(v.premiumPayment),
|
||||
coveragePeriodDays: numOrUndef(v.coveragePeriodDays),
|
||||
matchedPolicyId: policyId || undefined,
|
||||
matchedCustomerId: !policyId && customerId ? customerId : undefined,
|
||||
forceConfirm: true,
|
||||
@@ -332,6 +334,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
netPremium: reviewInput.netPremium,
|
||||
total: reviewInput.total,
|
||||
premiumPayment: reviewInput.premiumPayment,
|
||||
coveragePeriodDays: reviewInput.coveragePeriodDays,
|
||||
coveragesJson: (doc.extractedCoveragesJson ?? undefined) as
|
||||
| PolicyOcrCoverage[]
|
||||
| undefined,
|
||||
@@ -454,6 +457,17 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
onChange={(e) => set("policyDate", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{/* ANA sells 3- and 4-day tourist policies; left blank the
|
||||
póliza keeps the 365-day default. */}
|
||||
<Field label="Días de vigencia">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={v.coveragePeriodDays}
|
||||
onChange={(e) => set("coveragePeriodDays", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Moneda">
|
||||
<select
|
||||
className="input select"
|
||||
@@ -523,6 +537,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
<tr>
|
||||
<th>Riesgo</th>
|
||||
<th className="num">Suma</th>
|
||||
<th className="num">Prima</th>
|
||||
<th>Deducible</th>
|
||||
<th>Participación</th>
|
||||
</tr>
|
||||
@@ -534,6 +549,14 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
<td className="num">
|
||||
{formatMoney(c.insuredAmount?.toString() ?? null, v.currency)}
|
||||
</td>
|
||||
{/* ANA's add-on sections print what the coverage COST
|
||||
where the others print what it pays. Kept in its own
|
||||
column so the two are never added together. */}
|
||||
<td className="num">
|
||||
{c.premium == null
|
||||
? "—"
|
||||
: formatMoney(c.premium.toString(), v.currency)}
|
||||
</td>
|
||||
<td>{c.deductible ?? "—"}</td>
|
||||
<td>{c.lossParticipation ?? "—"}</td>
|
||||
</tr>
|
||||
@@ -543,6 +566,66 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* Vehicles and named drivers are read-only here: they are written
|
||||
* as their own Vehicle / InsuredDriver rows on confirm, and the
|
||||
* policy screen is where they get edited. Showing them is what
|
||||
* lets a reviewer catch a misread VIN before it is applied.
|
||||
*/}
|
||||
{doc.extractedVehiclesJson && doc.extractedVehiclesJson.length > 0 && (
|
||||
<details>
|
||||
<summary>Unidades ({doc.extractedVehiclesJson.length})</summary>
|
||||
<table className="tx-table" style={{ marginTop: 8 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tipo</th>
|
||||
<th>Año</th>
|
||||
<th>Marca</th>
|
||||
<th>Carrocería</th>
|
||||
<th>Serie</th>
|
||||
<th>Placas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{doc.extractedVehiclesJson.map((veh, i) => (
|
||||
<tr key={i}>
|
||||
<td>{veh.item}</td>
|
||||
<td>{veh.modelYear ?? "—"}</td>
|
||||
<td>{veh.make ?? "—"}</td>
|
||||
<td>{veh.bodyType ?? "—"}</td>
|
||||
<td>{veh.vinNumber ?? "—"}</td>
|
||||
<td>{veh.licensePlate ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{doc.extractedDriversJson && doc.extractedDriversJson.length > 0 && (
|
||||
<details>
|
||||
<summary>Conductores ({doc.extractedDriversJson.length})</summary>
|
||||
<table className="tx-table" style={{ marginTop: 8 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Licencia</th>
|
||||
<th>Teléfono</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{doc.extractedDriversJson.map((d, i) => (
|
||||
<tr key={i}>
|
||||
<td>{d.fullName}</td>
|
||||
<td>{d.licenseNumber ?? "—"}</td>
|
||||
<td>{d.phone ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{candidates.length > 1 && (
|
||||
<Field label="Póliza destino">
|
||||
<select
|
||||
|
||||
@@ -1432,7 +1432,7 @@ export function statementPageUrl(documentId: string): string {
|
||||
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------- Policy OCR (GMX) */
|
||||
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
|
||||
|
||||
export function getPolicyOcrStatus(): Promise<{
|
||||
ocrAvailable: boolean;
|
||||
|
||||
@@ -1405,7 +1405,7 @@ export interface DiscardBatchResult {
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ Policy OCR intake (GMX) */
|
||||
/* ------------------------------------- Policy OCR intake (GMX / ANA) */
|
||||
|
||||
export type PolicyOcrBatchStatus =
|
||||
| "UPLOADED"
|
||||
@@ -1447,6 +1447,27 @@ export interface PolicyOcrCoverage {
|
||||
insuredAmount: number | null;
|
||||
deductible: string | null;
|
||||
lossParticipation: string | null;
|
||||
/** What the coverage COST, where the layout prints it separately from what
|
||||
* it pays out (ANA's add-on sections). GMX never prints one. */
|
||||
premium?: number | null;
|
||||
}
|
||||
|
||||
/** A row of ANA's `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table. */
|
||||
export interface PolicyOcrVehicle {
|
||||
item: string;
|
||||
modelYear: string | null;
|
||||
make: string | null;
|
||||
bodyType: string | null;
|
||||
vinNumber: string | null;
|
||||
licensePlate: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyOcrDriver {
|
||||
fullName: string;
|
||||
licenseNumber: string | null;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyOcrMatchCandidate {
|
||||
@@ -1478,6 +1499,9 @@ export interface PolicyOcrDocument {
|
||||
extractedTotal: string | null;
|
||||
extractedCoveragesJson: PolicyOcrCoverage[] | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
extractedCoveragePeriodDays: number | null;
|
||||
extractedVehiclesJson: PolicyOcrVehicle[] | null;
|
||||
extractedDriversJson: PolicyOcrDriver[] | null;
|
||||
matchedPolicy: {
|
||||
id: string;
|
||||
policyNumber: string | null;
|
||||
@@ -1505,6 +1529,7 @@ export interface PolicyOcrReviewInput {
|
||||
brokerFee?: number;
|
||||
total?: number;
|
||||
premiumPayment?: string;
|
||||
coveragePeriodDays?: number;
|
||||
coveragesJson?: PolicyOcrCoverage[];
|
||||
matchedPolicyId?: string;
|
||||
matchedCustomerId?: string;
|
||||
@@ -1530,6 +1555,7 @@ export interface PolicyOcrConfirmDocument {
|
||||
brokerFee?: number;
|
||||
total?: number;
|
||||
premiumPayment?: string;
|
||||
coveragePeriodDays?: number;
|
||||
coveragesJson?: PolicyOcrCoverage[];
|
||||
postPremium?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user