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>
236 lines
7.6 KiB
TypeScript
236 lines
7.6 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import {
|
|
getPolicyOcrStatus,
|
|
listPolicyOcrBatches,
|
|
uploadPolicyOcrBatch,
|
|
} from "@/lib/api";
|
|
import { useCan } from "@/lib/abilities";
|
|
import { formatDate } from "@/lib/labels";
|
|
import type { PolicyOcrBatch, PolicyOcrBatchStatus } from "@/lib/types";
|
|
|
|
/**
|
|
* Insurance OCR intake — mirror of StatementIntake, scoped to the insurance
|
|
* 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
|
|
* hand that day, mixed service vs insurance), and the matching/review queue
|
|
* already keys on the policyNumber → existing Policy transition that the
|
|
* rest of /polizas owns.
|
|
*/
|
|
|
|
const STATUS_LABEL: Record<PolicyOcrBatchStatus, string> = {
|
|
UPLOADED: "Recibido",
|
|
PROCESSING: "Procesando…",
|
|
READY_FOR_REVIEW: "Listo para revisar",
|
|
COMPLETED: "Aplicado",
|
|
FAILED: "Falló",
|
|
DISCARDED: "Descartado",
|
|
};
|
|
|
|
export function PolicyOcrIntake() {
|
|
const canIngest = useCan("policy:ingest");
|
|
const [batches, setBatches] = useState<PolicyOcrBatch[]>([]);
|
|
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null);
|
|
const [storageAvailable, setStorageAvailable] = useState<boolean | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const [list, status] = await Promise.all([
|
|
listPolicyOcrBatches(),
|
|
getPolicyOcrStatus(),
|
|
]);
|
|
setBatches(list.items);
|
|
setOcrAvailable(status.ocrAvailable);
|
|
setStorageAvailable(status.storageAvailable);
|
|
setError(null);
|
|
} catch (e) {
|
|
setError((e as Error)?.message ?? "No se pudieron cargar los lotes.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const working = batches.some(
|
|
(b) => b.status === "PROCESSING" || b.status === "UPLOADED",
|
|
);
|
|
useEffect(() => {
|
|
if (!working) return;
|
|
const t = setInterval(() => void load(), 4000);
|
|
return () => clearInterval(t);
|
|
}, [working, load]);
|
|
|
|
const ready = ocrAvailable === true && storageAvailable === true;
|
|
|
|
return (
|
|
<div className="stack">
|
|
{ocrAvailable === false && (
|
|
<div className="state-box state-error">
|
|
Este servidor no tiene OCR instalado, así que no se pueden leer PDFs
|
|
de pólizas escaneados. La captura manual sigue funcionando.
|
|
</div>
|
|
)}
|
|
|
|
{storageAvailable === false && (
|
|
<div className="state-box state-error">
|
|
Este servidor no tiene configurado el almacenamiento de documentos, así
|
|
que no hay dónde guardar los PDFs. Mientras tanto, capture las
|
|
pólizas a mano.
|
|
</div>
|
|
)}
|
|
|
|
{canIngest && ready && <UploadCard onDone={load} />}
|
|
|
|
{error && <div className="state-box state-error">{error}</div>}
|
|
|
|
<section className="card" style={{ padding: 16 }}>
|
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
|
Lotes
|
|
</h2>
|
|
{loading ? (
|
|
<div className="state-box">Cargando…</div>
|
|
) : batches.length === 0 ? (
|
|
<div className="state-box">
|
|
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">
|
|
<table className="tx-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Fecha</th>
|
|
<th>Aseguradora</th>
|
|
<th>Referencia</th>
|
|
<th>Estado</th>
|
|
<th className="num">Páginas</th>
|
|
<th>Subido por</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{batches.map((b) => (
|
|
<tr key={b.id}>
|
|
<td style={{ whiteSpace: "nowrap" }}>{formatDate(b.createdAt)}</td>
|
|
<td>{b.provider}</td>
|
|
<td>{b.label || "—"}</td>
|
|
<td>
|
|
<StatusTag status={b.status} />
|
|
{b.error && (
|
|
<div className="page-sub" style={{ marginTop: 4 }}>
|
|
{b.error}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="num">{b._count?.documents ?? 0}</td>
|
|
<td>{b.uploadedBy?.name ?? "—"}</td>
|
|
<td>
|
|
<Link
|
|
className="btn btn-ghost"
|
|
href={`/polizas/captura/${b.id}`}
|
|
>
|
|
Revisar
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusTag({ status }: { status: PolicyOcrBatchStatus }) {
|
|
return <span className="tag">{STATUS_LABEL[status] ?? status}</span>;
|
|
}
|
|
|
|
function UploadCard({ onDone }: { onDone: () => void }) {
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
const [label, setLabel] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function submit() {
|
|
if (!files.length) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await uploadPolicyOcrBatch(files, label.trim() || undefined);
|
|
setFiles([]);
|
|
setLabel("");
|
|
onDone();
|
|
} catch (e) {
|
|
setError((e as Error)?.message ?? "No se pudo subir el lote.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="card" style={{ padding: 16 }}>
|
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
|
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. ANA agosto 2026"
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
<span className="page-sub">Archivos PDF</span>
|
|
<input
|
|
type="file"
|
|
className="input"
|
|
accept="application/pdf"
|
|
multiple
|
|
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
|
/>
|
|
</label>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={!files.length || busy}
|
|
onClick={submit}
|
|
>
|
|
{busy ? "Subiendo…" : `Procesar ${files.length || ""}`.trim()}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<p className="page-sub" style={{ marginTop: 12 }}>
|
|
Un lote puede traer varios PDFs. Cada página se procesa por separado; el
|
|
sistema busca una póliza existente por número y, si no la encuentra,
|
|
propone crear una nueva bajo el cliente que se elija en la revisión.
|
|
</p>
|
|
</section>
|
|
);
|
|
} |