feat(policy-ocr): set policyTypeId and insuranceProviderId on confirm
The BACKLOG claimed this was blocked on incomplete `policy_types` rows.
Querying the dev database says otherwise: AUTO (1316 policies) and LICENCIAS
(306) are both live and healthy, so ANA's two faces were never blocked at
all. Three separate things had been conflated.
What the parser now emits is a NAME, not an id -- it is a pure function over
text and must not reach for the database:
ANA AUTOMOBILE -> AUTO
ANA DRIVER'S POLICY -> LICENCIAS
GMX (both documents) -> MULT
`resolveLookups()` turns that into a foreign key at confirm, and does the
same for the carrier off the parser's provider code. It resolves, never
creates: a missing `policy_types` row means a human deleted it, and silently
recreating it would undo that with no record. An explicit `policyTypeId` /
`insuranceProviderId` on the confirm payload always wins.
GMX is MULT rather than INCENDIO because the caratula's own header reads
"Multiple Policy / Home" and the especificación is "PVL Hogar" -- one product,
two artifacts. MULT is the live row carrying 769 of them; INCENDIO is fire-only
and no policy in the book has ever used it.
The parser's provider code is not the carrier's row name, so PROVIDER_ROW_NAME
maps ANA onto "ANA SEGUROS", which is where the office's 738 ANA policies
already are.
--- the actual defect underneath -----------------------------------------
`policies.policyTypeId`, `policies.insuranceProviderId` and
`claims.adjusterId` are all ON DELETE SET NULL, and the lookups screen deleted
unconditionally. So deleting a lookup row returned 200 and silently blanked
the field on every row referencing it -- no error, nothing in the UI. That is
how M_EMPR disappeared and left 5 policies with no ramo, found months later
only by querying.
All three deletes now refuse while the row is in use, naming it and the count
("El tipo de póliza «M_EMPR» está en uso por 5 póliza(s)"). The schema-level
`onDelete: Restrict` the spec once recommended is deliberately not used: a raw
FK error is not something the operator can act on.
`20260815160000_policy_type_repair` cleans up what already happened:
- restores M_EMPR and re-points its 5 policies, scoped to
`policyTypeId IS NULL AND legacySourceTable = 'm_empr'` so it can never
claim a policy blanked for some other reason
- merges the duplicate "ANA" carrier (1 policy) into "ANA SEGUROS" (738).
OCR is about to start assigning the carrier automatically and two rows
would keep splitting the book. Written as joins, not subqueries, so both
statements are no-ops when either row is absent -- a subquery form would
resolve to NULL and blank the carrier off every ANA policy.
- does NOT restore INCENDIO. It is the other row the migration would have
produced, but the legacy INCENDIO table has 1 row that never loaded, so
the type has zero policies and restoring it would only put a dead option
in the type picker.
Verified by running the repair against the real broken dev data inside a
transaction and rolling back: 5 orphans -> 0, ANA/ANA SEGUROS -> one row with
739, and a second run in the same transaction changes nothing. The DDL half
matches `prisma migrate diff` exactly.
186 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+49
-3
@@ -34,7 +34,7 @@ was **reused, not copied**.
|
||||
|---|---|
|
||||
| API module | `apps/api/src/policy-ocr/` (service, controller, DTOs, matcher, parser) |
|
||||
| Shared OCR seam | `apps/api/src/ocr/ocr.module.ts` |
|
||||
| Tables | `policy_ocr_batches`, `policy_ocr_documents` (`20260801000000_policy_ocr_intake`, extended by `20260815120000_policy_ocr_ana`) |
|
||||
| Tables | `policy_ocr_batches`, `policy_ocr_documents` (`20260801000000_policy_ocr_intake`, extended by `20260815120000_policy_ocr_ana` and `20260815160000_policy_type_repair`) |
|
||||
| Web | `components/PolicyCaptura.tsx` (tab shell), `PolicyOcrIntake.tsx` (upload), `PolicyOcrReview.tsx` (review queue) |
|
||||
| Abilities | `policy:ingest`, `policy:ocr-review` — both **STAFF** |
|
||||
|
||||
@@ -320,13 +320,55 @@ Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
|
||||
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
|
||||
and free text, and normalising them would lose the distinction.
|
||||
|
||||
## Policy type and carrier
|
||||
|
||||
Confirm sets `Policy.policyTypeId` and `Policy.insuranceProviderId` from what
|
||||
the parser read.
|
||||
|
||||
| document | `policyTypeName` |
|
||||
|---|---|
|
||||
| ANA `AUTOMOBILE` | `AUTO` |
|
||||
| ANA `DRIVER´S POLICY` | `LICENCIAS` |
|
||||
| GMX caratula **and** especificación | `MULT` |
|
||||
|
||||
The parser emits a **name**, never an id — it is a pure function over text and
|
||||
must not reach for the database, so `resolveLookups()` in the service turns the
|
||||
name into a foreign key. A renamed lookup row is then a data change rather than
|
||||
a parser change.
|
||||
|
||||
**Resolve, never create.** A missing `policy_types` row means a human deleted
|
||||
it, and silently recreating it would undo that with no record. The field stays
|
||||
null and the reviewer adds the row through the lookups screen. An explicit
|
||||
`policyTypeId` / `insuranceProviderId` on the confirm payload always wins.
|
||||
|
||||
Two judgement calls worth recording:
|
||||
|
||||
- **GMX is `MULT`, not `INCENDIO`.** The caratula's own header reads "Multiple
|
||||
Policy / Home" and the especificación is "PVL Hogar" — one product, two
|
||||
artifacts. `MULT` is the live row carrying 769 of them; `INCENDIO` is
|
||||
fire-only and no policy in the book has ever used it.
|
||||
- **The parser's provider code is not the carrier's row name.** The office's
|
||||
book is filed under `ANA SEGUROS`, so `PROVIDER_ROW_NAME` maps `ANA` onto it.
|
||||
A bare `ANA` row with 1 policy also existed and is merged away by
|
||||
`20260815160000_policy_type_repair`.
|
||||
|
||||
> **Deleting a lookup row used to be silent data loss.** `policies.policyTypeId`,
|
||||
> `policies.insuranceProviderId` and `claims.adjusterId` are all
|
||||
> `ON DELETE SET NULL`, and the lookups screen deleted unconditionally — so the
|
||||
> delete returned 200 and blanked the field on every row that used it. That is
|
||||
> how `M_EMPR` vanished and left 5 policies with no ramo, found months later by
|
||||
> querying. All three deletes now refuse while the row is in use, naming it and
|
||||
> the count. See `assertLookupUnused` and BACKLOG §2.1.
|
||||
|
||||
## Confirm: what actually gets written
|
||||
|
||||
Per confirmed document, in order:
|
||||
|
||||
1. **The `Policy` row** — updated if a policy was matched, created under the
|
||||
picked customer if not. Only non-null `extracted*` fields are written; null
|
||||
never overwrites existing data.
|
||||
never overwrites existing data. `policyTypeId` and `insuranceProviderId` are
|
||||
resolved first (above) and left untouched when unresolvable, so an existing
|
||||
policy never loses a type or carrier it already had.
|
||||
2. **`Vehicle` and `InsuredDriver` rows** — for the providers whose face
|
||||
carries them (A.N.A.; never GMX Hogar), skipping any that already exist on
|
||||
the policy. See *Vehicles and drivers* above.
|
||||
@@ -374,7 +416,7 @@ feature is disabled.
|
||||
|
||||
## Tests
|
||||
|
||||
`apps/api/src/policy-ocr/parsers/policy-parser.spec.ts` — 53 cases against
|
||||
`apps/api/src/policy-ocr/parsers/policy-parser.spec.ts` — 58 cases against
|
||||
verbatim text extracted from five real documents, indentation and blank lines
|
||||
included (the column positions are what the parser reads, so a cleaned-up
|
||||
fixture would test nothing — and on A.N.A.'s driver's policy the offsets are
|
||||
@@ -398,6 +440,10 @@ sublimit block whose amount sits after both a blank line and a page break,
|
||||
the excluded earthquake coverage, and the hydrometeorological deductible and
|
||||
coinsurance pulled from their own per-zone block.
|
||||
|
||||
Plus four cases in `apps/api/src/policies/lookup-delete-guard.spec.ts` pinning
|
||||
the refusal that stops a lookup delete from silently blanking the rows that use
|
||||
it, and four in the parser suite on the policy-type NAME each document yields.
|
||||
|
||||
From the three A.N.A. PDFs: brand detection (and that GMX's layout rules
|
||||
cannot claim an ANA page), the header band, DD MM YYYY read out of three
|
||||
separate column cells, the six money cells with `DISCOUNT` printed as a bare
|
||||
|
||||
Reference in New Issue
Block a user