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>
124 lines
4.1 KiB
TypeScript
124 lines
4.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { PolicyForm } from "@/components/PolicyForm";
|
|
import { PolicyOcrIntake } from "@/components/PolicyOcrIntake";
|
|
import { useCan } from "@/lib/abilities";
|
|
|
|
/**
|
|
* Policy intake — mirror of `Captura.tsx` (statement OCR side): one screen,
|
|
* two ways in:
|
|
*
|
|
* - **manual** — `PolicyForm` keys every field by hand.
|
|
* - **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
|
|
* live as two modes of one screen rather than two menu entries — exactly the
|
|
* same shape Captura uses for `ManualCheckCapture` vs `StatementIntake`.
|
|
*
|
|
* `/polizas/nuevo` opens manual, `/polizas/captura` opens auto; both render
|
|
* this component so the tab toggle works either way and an old bookmark
|
|
* still lands on the right tab.
|
|
*/
|
|
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 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 }) {
|
|
const canCreate = useCan("policy:create");
|
|
const canIngest = useCan("policy:ingest");
|
|
|
|
// `/clientes/[id]` deep-links into /polizas/nuevo with the customer
|
|
// pre-picked so staff can fill the rest without retyping. The OCR pane
|
|
// ignores these — there's no customer to lock in until the batch is
|
|
// confirmed.
|
|
const params = useSearchParams();
|
|
const fixedCustomerId = params.get("customerId") ?? undefined;
|
|
const fixedCustomerName = params.get("customerName") ?? undefined;
|
|
|
|
// One user can land on either mode. The tab strip only renders when both
|
|
// abilities are held — a STAFF with only policy:ingest (no create) still
|
|
// sees the screen but only the OCR tab is offered.
|
|
const modes: { key: PolicyCaptureMode; label: string }[] = [
|
|
...(canCreate ? [{ key: "manual" as const, label: "Captura manual" }] : []),
|
|
...(canIngest ? [{ key: "auto" as const, label: "Captura automática (OCR)" }] : []),
|
|
];
|
|
|
|
const [mode, setMode] = useState<PolicyCaptureMode>(
|
|
modes.some((m) => m.key === initialMode) ? initialMode : (modes[0]?.key ?? "manual"),
|
|
);
|
|
|
|
if (modes.length === 0) {
|
|
return (
|
|
<div className="state-box state-error">
|
|
No tienes permiso para crear ni capturar pólizas.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="page-head">
|
|
<Link href="/polizas" className="back-link">← Pólizas</Link>
|
|
<h1 className="page-title">Nueva póliza</h1>
|
|
<p className="eyebrow">{MODE_HINT[mode]}</p>
|
|
</div>
|
|
|
|
{modes.length > 1 && (
|
|
<div className="seg" role="tablist" style={{ marginBottom: 16 }}>
|
|
{modes.map((m) => (
|
|
<button
|
|
key={m.key}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={mode === m.key}
|
|
className={`seg-btn ${mode === m.key ? "active" : ""}`}
|
|
onClick={() => setMode(m.key)}
|
|
>
|
|
{m.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{mode === "manual" ? (
|
|
<ManualPane
|
|
fixedCustomerId={fixedCustomerId}
|
|
fixedCustomerName={fixedCustomerName}
|
|
/>
|
|
) : (
|
|
<PolicyOcrIntake />
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ManualPane({
|
|
fixedCustomerId,
|
|
fixedCustomerName,
|
|
}: {
|
|
fixedCustomerId?: string;
|
|
fixedCustomerName?: string;
|
|
}) {
|
|
const allowed = useCan("policy:create");
|
|
if (!allowed) {
|
|
return (
|
|
<div className="state-box state-error">
|
|
No tiene permisos para crear pólizas.
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<PolicyForm
|
|
fixedCustomerId={fixedCustomerId}
|
|
fixedCustomerName={fixedCustomerName}
|
|
/>
|
|
);
|
|
} |