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
@@ -0,0 +1,55 @@
-- The policy type the OCR parser read the product as, resolved to a
-- `policy_types` row at confirm time.
ALTER TABLE `policy_ocr_documents`
ADD COLUMN `extractedPolicyTypeName` VARCHAR(191) NULL;
-- ---------------------------------------------------------------------------
-- Repair: M_EMPR was deleted from the lookups screen and took its policies'
-- type with it.
--
-- `policies.policyTypeId` is ON DELETE SET NULL, and removePolicyType() had no
-- in-use guard, so deleting the row silently blanked the field on every policy
-- referencing it — 5 of them, all from the legacy `m_empr` table. The guard
-- against a repeat ships in the same change as this migration. What follows
-- repairs what already happened.
--
-- Idempotent on purpose: `policy_types.name` is UNIQUE so the INSERT IGNORE is
-- a no-op once the row exists, and the UPDATE is scoped to rows that are still
-- null AND came from that one legacy table, so it can never claim a policy
-- whose type was blanked for some other reason.
INSERT IGNORE INTO `policy_types` (`id`, `name`) VALUES (UUID(), 'M_EMPR');
UPDATE `policies` p
JOIN `policy_types` pt ON pt.`name` = 'M_EMPR'
SET p.`policyTypeId` = pt.`id`
WHERE p.`policyTypeId` IS NULL
AND p.`legacySourceTable` = 'm_empr';
-- INCENDIO is deliberately NOT recreated. It is the other row the migration
-- would have produced, but no policy in the book has ever carried it, so
-- adding it back would only put a dead option in the type picker.
-- ---------------------------------------------------------------------------
-- Merge the duplicate ANA carrier.
--
-- `insurance_providers` holds both "ANA" (1 policy) and "ANA SEGUROS" (738).
-- They are one carrier, and OCR is about to start assigning it automatically —
-- picking either row while both exist would keep splitting the book.
--
-- "ANA SEGUROS" is the survivor because it is where the 738 already are.
--
-- Written as joins rather than subqueries so that BOTH statements are no-ops
-- when either row is absent (a fresh database, or one where this was already
-- tidied by hand). A subquery form would resolve to NULL and blank the
-- carrier off every ANA policy.
UPDATE `policies` p
JOIN `insurance_providers` dup ON dup.`id` = p.`insuranceProviderId` AND dup.`name` = 'ANA'
JOIN `insurance_providers` keep ON keep.`name` = 'ANA SEGUROS'
SET p.`insuranceProviderId` = keep.`id`;
DELETE dup FROM `insurance_providers` dup
JOIN `insurance_providers` keep ON keep.`name` = 'ANA SEGUROS'
WHERE dup.`name` = 'ANA'
-- Belt and braces: never drop a row that still has policies hanging off
-- it, whatever the UPDATE above did or did not manage to move.
AND NOT EXISTS (SELECT 1 FROM `policies` p WHERE p.`insuranceProviderId` = dup.`id`);
+5
View File
@@ -461,6 +461,11 @@ model PolicyOcrDocument {
/// POLICY HOLDER list on its driver's policy. Written to `InsuredDriver`
/// rows on confirm.
extractedDriversJson Json?
/// The `PolicyType.name` the parser read the product as ("AUTO",
/// "LICENCIAS", "MULT"). A NAME, not an id — the parser never touches the
/// database, so confirm resolves it against `policy_types` and leaves
/// `Policy.policyTypeId` null if there is no such row.
extractedPolicyTypeName String?
// Match by `Policy.policyNumber` → existing Policy / Customer.
matchedPolicyId String?