feat(policy-ocr): set policyTypeId and insuranceProviderId on confirm
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m0s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m14s

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:
2026-08-15 01:28:26 -07:00
co-authored by Claude Opus 5
parent 5a277f4885
commit 022d1935ad
12 changed files with 444 additions and 27 deletions
+28 -13
View File
@@ -12,8 +12,8 @@ carries the reasoning. Close an item *there* as well as here, or the two drift.
**Verified against dev at compile time** (re-run before trusting the numbers):
```
policy_types: AUTO, LICENCIAS, MULT
policies NULL policyTypeId: 5
policy_types: AUTO, LICENCIAS, MULT (+ M_EMPR after 20260815160000)
policies NULL policyTypeId: 5 (0 after 20260815160000)
policies pending liquidación: 226
customers: 1536
last tag: v1.0.6 (2026-08-02 02:06 UTC) — 14 commits, 5 migrations behind HEAD
@@ -98,16 +98,31 @@ in the same phone call — (55) 5480-4000.
## 2. Live data defects — open, and confirmed open today
### 2.1 `policy_types` is missing `INCENDIO` and `M_EMPR`, and 5 policies are orphaned
### 2.1 ~~`policy_types` missing rows + 5 orphaned policies~~ — FIXED 2026-08-15
`policyTypeId` is `String?` with a plain relation, so Prisma's default is
`SetNull`. The spec's recommended `onDelete: Restrict` was **never applied**.
Five `m_empr` policies lost their ramo; four of them are pending liquidación
and are invisible to every ramo-filtered query — including the pending report
§2 is supposed to produce.
`SetNull`, and `removePolicyType()` had no in-use guard — deleting a lookup row
returned 200 and silently blanked the ramo on every policy using it. That is
what happened to `M_EMPR` and its 5 `m_empr` policies.
Fix alongside the liquidación work (3.1), since it distorts that feature's own
report. Source: INSURANCE "Two defects found while verifying this spec".
Closed by `20260815160000_policy_type_repair` plus the guard in
`policies.service.ts`:
- `M_EMPR` restored and the 5 policies re-pointed at it, scoped to
`policyTypeId IS NULL AND legacySourceTable = 'm_empr'` so it cannot claim a
policy blanked for some other reason. Idempotent; verified against dev inside
a rolled-back transaction.
- **`INCENDIO` deliberately not recreated.** The legacy `INCENDIO` table has
1 row and it never loaded, so the type has zero policies — restoring it would
only add a dead option to the type picker.
- Deleting an in-use policy type, carrier or adjuster now **refuses** with the
name and the count. `claims.adjusterId` had the identical `SET NULL` trap and
is guarded too. `onDelete: Restrict` at the schema level was not applied —
the application guard gives a Spanish message the operator can act on, where
a raw FK error would not.
- The duplicate `ANA` carrier row (1 policy) was merged into `ANA SEGUROS`
(738), since OCR now assigns the carrier automatically and two rows would
keep splitting the book.
### 2.2 ≤41 MULT second settlements were dropped in migration
@@ -172,10 +187,10 @@ Each of these is a known, deliberate stopping point rather than a bug.
all; reading the separate receipt and pairing it to its certificate is what
would let `postPremium` stop being a manual tick. A.N.A. prints its premium
on the face, so this is a GMX-only gap.
- **No `policyTypeId` from OCR.** A.N.A.'s two faces are distinguishable in the
parser (automobile vs driver's policy) and the platform has a `PolicyType`
discriminator, but confirm never sets one — partly because the `policy_types`
rows are themselves incomplete (INSURANCE §live defects).
- **No `insuranceProviderId` beyond the two OCR carriers.** Confirm resolves
the parser's provider to an `insurance_providers` row by name, so GMX and
A.N.A. land correctly; a policy typed in by hand still gets whatever the
operator picks.
- **No versioning.** A re-issued policy arrives as a new certificate with the
same number and confirm updates the existing row. Nothing records that this
is the 2027 issue of that policy.
+49 -3
View File
@@ -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