From d645ba51d303c00f17b6001d3a9a68a5b7ccf992 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Sat, 15 Aug 2026 01:09:16 -0700 Subject: [PATCH] feat(policy-ocr): read A.N.A. Seguros' two policy faces 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 --- .../policy-ocr/parsers/policy-parser.spec.ts | 474 +++++++- .../src/policy-ocr/parsers/policy-parser.ts | 1029 ++++++++++++++++- apps/api/src/policy-ocr/policy-ocr.dto.ts | 7 + apps/api/src/policy-ocr/policy-ocr.service.ts | 165 ++- apps/web/src/app/polizas/[id]/page.tsx | 75 +- apps/web/src/app/polizas/captura/page.tsx | 2 +- apps/web/src/components/PolicyCaptura.tsx | 4 +- apps/web/src/components/PolicyOcrIntake.tsx | 17 +- apps/web/src/components/PolicyOcrReview.tsx | 83 ++ apps/web/src/lib/api.ts | 2 +- apps/web/src/lib/types.ts | 28 +- docs/BACKLOG.md | 13 +- docs/POLICY_OCR.md | 141 ++- .../migration.sql | 12 + packages/database/prisma/schema.prisma | 29 +- 15 files changed, 1990 insertions(+), 91 deletions(-) create mode 100644 packages/database/prisma/migrations/20260815120000_policy_ocr_ana/migration.sql diff --git a/apps/api/src/policy-ocr/parsers/policy-parser.spec.ts b/apps/api/src/policy-ocr/parsers/policy-parser.spec.ts index 361fa58..68c1c1a 100644 --- a/apps/api/src/policy-ocr/parsers/policy-parser.spec.ts +++ b/apps/api/src/policy-ocr/parsers/policy-parser.spec.ts @@ -15,6 +15,11 @@ function page(text: string): OcrPage { return { text, words: [], confidence: 0.95 }; } +/** Coverages keyed by their risk label, so an assertion names the coverage + * it is about instead of an array index that shifts when one is added. */ +const byRisk = (p: ReturnType): Record => + Object.fromEntries(p.coverages.map((c) => [c.risk, c])); + describe("detectPolicyProvider", () => { it("claims GMX from the brand wordmark on the letterhead", () => { expect( @@ -280,9 +285,6 @@ describe("parsePolicy / GMX especificación (PVL Hogar)", () => { "PVL Hogar - GMX Seguros Página: 10 de 10\n", ); - const byRisk = (p: ReturnType) => - Object.fromEntries(p.coverages.map((c) => [c.risk, c])); - it("reads a policy number whose groups are not the caratula's widths", () => { // 2-3-8-5-2 here vs 3-3-8-4-2 on the English caratula. Pinning the widths // reads one family and returns null on the other. @@ -411,4 +413,468 @@ describe("parsePolicy / GMX especificación (PVL Hogar)", () => { expect(notes).toMatch(/zona catastrófica declarada: A2/); expect(notes).toMatch(/W_HogarGMX_12\.11\.2025\.pdf/); }); -}); \ No newline at end of file +}); + +/* ------------------------------------------------------------------ ANA */ + +/** + * Verbatim `pdftotext -layout` output of the PDFs A.N.A.'s portal produced + * for three real policies, cut at the end of the risk table (the legal + * boilerplate and the repeated AGENT COPY below it are not parsed, and the + * repeats are covered by their own test). + * + * The column padding is load-bearing on the driver's policy, which + * distinguishes SUM INSURED from PREMIUM by horizontal position alone — do + * not reflow these strings. + */ + +const ANA_AUTO_AMPLIA = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV +LUIS CABRERA #2033 INT. 201, Col. ZONA URBANA RIO TIJUANA +C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA +www.anaseguros.com.mx +AUTOMOBILE + ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO +U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9 SPECIAL POLICY FOR TOURISTS +TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266 +WHATSAPP + 52-55-80-50-3633 + No. 700489651 + ISSUED BY: DATE ISSUED TERM OF INSURANCE + DAYS +JORGE HUMBERTO CUADROS DAY MONTH YEAR DAY MONTH YEAR TIME +BENITO JUAREZ 25 No.50 INT 38 CENTRO + 04 08 2026 FROM 07 08 2026 12:01 + 365 +ROSARITO, BAJA CALIFORNIA 22710 +. 70175 TO 07 08 2027 12:01 + DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL + - 298.61 30.00 26.29 0.00 354.90 + + INSURED RAY DEAN II AND SUSAN ROCKHOLD + LICENSE P0066762 + ADDRESS 10308 DONNA AVE EMAIL PROLABSALE@AOL.COM + CITY & STATE NORTHRIDGE, CA 91326 TELEPHONE 8184453524 + PAYMENT DEADLINE + INSURANCE COMPANY LIEN HOLDER + IMMEDIATE + + ITEM YEAR MAKE BODY SERIAL No. PLATES +VEHICLE 2017 CHRYSLER PACIFICA 2C4RC1DG7HR654698 8BPX206 +TRAILER . . +TOWING . . +*** VALUE STATED MUST NOT EXCEED MARKET VALUE *** +***VEHICLES THAT HAVE BEEN ACQUIRED AS SALVAGE, REBUILT, OR HAVE BEEN USED PREVIOUSLY AS A TAXI WILL BE CONSIDERED WITH A REDUCED VALUE OF 35% (thirty-five percent), TAKING +AS A BASE THE VALUE OF A SIMILAR NORMAL VEHICLE, THAT IS, ONE THAT HAS NOT BEEN ACQUIRED AS SALVAGE AND ITS PREVIOUS USE HAS NOT BEEN AS A TAXI OR REBUILT. IT WILL BE THE +SOLE OBLIGATION AND RESPONSIBILITY OF THE INSURED TO DECLARATE THIS WHEN ACQUIRING THE POLICY. +SECTION SPECIFICATION OF RISKS LIMIT OF LIABILITY + MATERIAL DAMAGE WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED VEHICLE 8,000.00 DLLS. + 1 DEDUCTIBLE: WITH MINIMUM OF $500.00 ON AUTOS + TRAILER + (SEDANS, COUPES, CONVERTIBLES AND STATION WAGONS) COVERED + AND $500.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND MOTOR HOMES). 0.00 DLLS. + + TOTAL THEFT WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED TOWING + 2 DEDUCTIBLE: WITH MINIMUM OF $1,000.00 ON AUTOS 0.00 DLLS. + (SEDANS, COUPES, CONVERTIBLES AND STATION WAGONS) COVERED + AND $1,000.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND MOTOR HOMES). + LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES + 3 100,000.00 DLLS. + + + BODILY INJURY LIABILITY PER PER + 4 PERSON 100,000.00 ACCIDENT 200,000.00 DLLS. + + MEDICAL EXPENSES PER PER + 5 PERSON 5,000.00 ACCIDENT 25,000.00 DLLS. + + COVERED/EXCLUDED PREMIUM + 6 A.N.A.'s LEGAL AID + COVERED 40.00 + COVERED/EXCLUDED PREMIUM + 7 A.N.A.'s ROADSIDE ASSISTANCE + COVERED 40.00 + CATASTROPHIC LIABILITY FOR DEATH OF THIRD PREMIUM + 8 EXCLUDED + PARTIES DLLS. 0.00 + ELITE OR ELITE PLUS WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED + 9 PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) 0.00 + VANDALISM (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) EXCLUDED + + +ISSUED ONLINE`); + +const ANA_AUTO_RC_DIAS = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV +LUIS CABRERA #2033 INT. 201, Col. ZONA URBANA RIO TIJUANA +C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA +www.anaseguros.com.mx +AUTOMOBILE + ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO +U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9 SPECIAL POLICY FOR TOURISTS +TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266 +WHATSAPP + 52-55-80-50-3633 + No. 700487807 + ISSUED BY: DATE ISSUED TERM OF INSURANCE + DAYS +JORGE HUMBERTO CUADROS DIARIA DAY MONTH YEAR DAY MONTH YEAR TIME +BENITO JUAREZ 25 NO50 INT 38 COL CENTRO + 22 07 2026 FROM 23 07 2026 12:01 + 3 +ROSARITO BAJA CALIFORNIA 22710 +(661) 612 12 55 70175 TO 26 07 2026 12:01 + DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL + - 10.77 25.00 2.86 0.00 38.63 + + INSURED STEPHEN RUPAN SHATAFIAN + LICENSE C1394198 + ADDRESS 13181 CROSSROADS PARKWAY NORTH STE 300 EMAIL sshatafian@lee-associates.com + + + CITY & STATE CITY OF INDUSTRY, CA 91746 TELEPHONE 7143221072 + PAYMENT DEADLINE + INSURANCE COMPANY LIEN HOLDER + IMMEDIATE + + ITEM YEAR MAKE BODY SERIAL No. PLATES +VEHICLE 2022 FORD TRANSIT 1FBAX2CG3NKA69091 EC46T99 +TRAILER . . +TOWING . . +*** VALUE STATED MUST NOT EXCEED MARKET VALUE *** +***VEHICLES THAT HAVE BEEN ACQUIRED AS SALVAGE, REBUILT, OR HAVE BEEN USED PREVIOUSLY AS A TAXI WILL BE CONSIDERED WITH A REDUCED VALUE OF 35% (thirty-five percent), TAKING +AS A BASE THE VALUE OF A SIMILAR NORMAL VEHICLE, THAT IS, ONE THAT HAS NOT BEEN ACQUIRED AS SALVAGE AND ITS PREVIOUS USE HAS NOT BEEN AS A TAXI OR REBUILT. IT WILL BE THE +SOLE OBLIGATION AND RESPONSIBILITY OF THE INSURED TO DECLARATE THIS WHEN ACQUIRING THE POLICY. +SECTION SPECIFICATION OF RISKS LIMIT OF LIABILITY + MATERIAL DAMAGE WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED VEHICLE 0.00 DLLS. + 1 DEDUCTIBLE: ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND + TRAILER + STATION WAGONS) AND OTHERS (PICK UPS, VANS, EXCLUDED + SUV´s AND MOTOR HOMES). 0.00 DLLS. + + TOTAL THEFT WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED TOWING + 2 DEDUCTIBLE: ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND 0.00 DLLS. + STATION WAGONS) AND OTHERS (PICK UPS, VANS, EXCLUDED + SUV´s AND MOTOR HOMES). + LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES + 3 100,000.00 DLLS. + + + BODILY INJURY LIABILITY PER PER + 4 PERSON 100,000.00 ACCIDENT 200,000.00 DLLS. + + MEDICAL EXPENSES PER PER + 5 PERSON 5,000.00 ACCIDENT 25,000.00 DLLS. + + COVERED/EXCLUDED PREMIUM + 6 A.N.A.'s LEGAL AID + COVERED 2.25 + COVERED/EXCLUDED PREMIUM + 7 A.N.A.'s ROADSIDE ASSISTANCE + COVERED 2.25 + CATASTROPHIC LIABILITY FOR DEATH OF THIRD PREMIUM + 8 EXCLUDED + PARTIES DLLS. 0.00 + ELITE OR ELITE PLUS WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED + 9 PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) 0.00 + VANDALISM (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) EXCLUDED + + +ISSUED ONLINE`); + +const ANA_LICENCIA = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV +LUIS CABRERA #2033 INT. 201, Col.4 ZONA URBANA RIO TIJUANA +C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA +www.anaseguros.com.mx + DRIVER´S POLICY FOR AUTOMOBILE + ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO +U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9 + SPECIAL POLICY FOR TOURISTS +TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266 +WHATSAPP + 52-55-80-50-3633 No. 700489616 + ISSUED BY: DATE ISSUED & TIME TERM OF INSURANCE +JORGE HUMBERTO CUADROS + DAYS + DAY MONTH YEAR DAY MONTH YEAR TIME +BENITO JUAREZ 25 No.50 INT 38 CENTRO 04 08 2026 FROM 06 08 2026 12:01 + 365 +ROSARITO, BAJA CALIFORNIA 22710 TO 06 08 2027 12:01 +. 70175 + DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL + - 142.78 30.00 13.82 0.00 186.60 + + LICENSE N0017668 EMAIL PWAGONER49@AOL.COM TELEPHONE 3102001538 + POLICY HOLDER + 1. NAME : PAMELA DENISE WAGONER Ph.3102001538 + ADDRESS : 49305 HIGHWAY 74 SPC 10, PALM DESERT, CA, 92260, + DRIVER LICENSE : N0017668 + 2. NAME : + ADDRESS : + DRIVER LICENSE : + NONE + 3. NAME : + ADDRESS : + DRIVER LICENSE : + NONE + 4. NAME : + ADDRESS : + DRIVER LICENSE : NONE + 5. NAME : + ADDRESS : + DRIVER LICENSE : NONE + SPECIFICATION OF RISKS SUM INSURED PREMIUM + LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES 100,000.00 usd. 18.70 usd. + BODILY INJURY LIABILITY ( EXCLUDING OCCUPANTS OF THE VEHICLE ) 100,000.00 usd. Per Person + 54.27 usd. + 200,000.00 usd. Per Accident + CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES 0.00 usd. 0.00 usd. + + MEDICAL EXPENSES 4,000.00 usd. Per Person + 9.81 usd. + 20,000.00 usd. Per Accident + COVERED/EXCLUDED PREMIUM + LEGAL AID + COVERED 30.00 usd. + COVERED/EXCLUDED PREMIUM + AUTOMOBILE ASSISTANCE + COVERED 30.00 usd. + + The following risks are excluded Collision, overtuning and glass breakage, fire, total theft and natural disasters, partial theft and vandalism.`); + + +describe("detectPolicyProvider / ANA", () => { + it("claims ANA from the letterhead", () => { + expect( + detectPolicyProvider("A.N.A. COMPAÑIA DE SEGUROS SA DE CV\nwww.anaseguros.com.mx"), + ).toBe("ANA"); + }); + + it("does not let GMX's layout rules claim an ANA page", () => { + // Both books print "MATERIAL DAMAGE"-ish headings; the brand pass runs + // before any layout rule precisely so this can't go the other way. + expect(detectPolicyProvider(ANA_AUTO_AMPLIA.text)).toBe("ANA"); + expect(detectPolicyProvider(ANA_LICENCIA.text)).toBe("ANA"); + }); +}); + +describe("parsePolicy / ANA automobile", () => { + const p = parsePolicy(ANA_AUTO_AMPLIA); + + it("reads the header band", () => { + expect(p.provider).toBe("ANA"); + expect(p.policyNumber).toBe("700489651"); + expect(p.insuredName).toBe("RAY DEAN II AND SUSAN ROCKHOLD"); + expect(p.agentName).toBe("JORGE HUMBERTO CUADROS"); + expect(p.legalAddress).toBe("10308 DONNA AVE, NORTHRIDGE, CA 91326"); + expect(p.zip).toBe("91326"); + expect(p.currency).toBe("USD"); + expect(p.premiumPayment).toBe("IMMEDIATE"); + }); + + it("reads DD MM YYYY out of the three date column cells", () => { + expect(p.policyDate?.toISOString().slice(0, 10)).toBe("2026-08-04"); + expect(p.policyFrom?.toISOString().slice(0, 10)).toBe("2026-08-07"); + expect(p.policyTo?.toISOString().slice(0, 10)).toBe("2027-08-07"); + }); + + it("maps the six money cells positionally, not by finding six amounts", () => { + // DISCOUNT prints as a bare "-" here. A "take the amounts in order" + // reading would shift every value one column left. + expect(p.netPremium).toBe(298.61); + expect(p.policyFee).toBe(30); + expect(p.total).toBe(354.9); + expect(p.notes.join(" | ")).toMatch(/impuesto: 26\.29/); + }); + + it("reads the vehicle by token role, not by column", () => { + expect(p.vehicles).toHaveLength(1); + expect(p.vehicles[0]).toEqual({ + item: "VEHICLE", + modelYear: "2017", + make: "CHRYSLER", + bodyType: "PACIFICA", + vinNumber: "2C4RC1DG7HR654698", + licensePlate: "8BPX206", + }); + }); + + it("reads a two-word BODY cell without losing the VIN", () => { + // "GENESIS SEDAN" is two tokens where "PACIFICA" is one — the VIN shape + // is the anchor, not the token count. + const v = parsePolicy(ANA_AUTO_RC_DIAS).vehicles[0]; + expect(v.make).toBe("FORD"); + expect(v.vinNumber).toBe("1FBAX2CG3NKA69091"); + expect(v.licensePlate).toBe("EC46T99"); + }); + + it("skips the empty TRAILER and TOWING slots", () => { + // Both print a "." per cell rather than being absent. + expect(p.vehicles.map((v) => v.item)).toEqual(["VEHICLE"]); + }); + + it("records the insured as a named driver with their licence", () => { + expect(p.drivers).toHaveLength(1); + expect(p.drivers[0].fullName).toBe("RAY DEAN II AND SUSAN ROCKHOLD"); + expect(p.drivers[0].licenseNumber).toBe("P0066762"); + expect(p.drivers[0].email).toBe("PROLABSALE@AOL.COM"); + }); + + it("does not read the agent's own street number as the policy number", () => { + // "BENITO JUAREZ 25 No.50 INT 38" sits three lines above the No. cell. + expect(p.policyNumber).not.toBe("50"); + expect(p.notes.join(" | ")).not.toMatch(/formas/); + }); + + it("reads the agent clave without picking up their postal code", () => { + // "ROSARITO, BAJA CALIFORNIA 22710" is five digits in the same band. + expect(p.notes.join(" | ")).toMatch(/clave de agente: 70175/); + expect(p.notes.join(" | ")).not.toMatch(/22710/); + }); + + it("labels the declared values by their printed item slot", () => { + const c = byRisk(p); + expect(c["MATERIAL DAMAGE — VEHICLE"]?.insuredAmount).toBe(8000); + expect(c["MATERIAL DAMAGE — TRAILER"]?.insuredAmount).toBe(0); + expect(c["TOTAL THEFT — TOWING"]?.insuredAmount).toBe(0); + }); + + it("keeps the deductible sentence out of the value columns", () => { + const c = byRisk(p); + expect(c["MATERIAL DAMAGE — VEHICLE"]?.deductible).toBe( + "WITH MINIMUM OF $500.00 ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND " + + "STATION WAGONS) AND $500.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND " + + "MOTOR HOMES).", + ); + }); + + it("does not mistake the $500.00 inside the deductible for a sum insured", () => { + // It is the one amount in the block not suffixed "DLLS.". + const amounts = p.coverages.map((c) => c.insuredAmount); + expect(amounts).not.toContain(500); + }); + + it("splits the per-person and per-accident limits", () => { + const c = byRisk(p); + expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.insuredAmount).toBe(100000); + expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.insuredAmount).toBe(200000); + expect(c["MEDICAL EXPENSES — POR PERSONA"]?.insuredAmount).toBe(5000); + expect(c["MEDICAL EXPENSES — POR EVENTO"]?.insuredAmount).toBe(25000); + }); + + it("records an add-on's figure as a premium, never as a sum insured", () => { + // $40 is what legal aid COST. As `insuredAmount` it would read on the + // review screen as a $40 liability limit. + const c = byRisk(p); + expect(c["LEGAL AID"]?.premium).toBe(40); + expect(c["LEGAL AID"]?.insuredAmount).toBeNull(); + expect(c["ROADSIDE ASSISTANCE"]?.premium).toBe(40); + }); + + it("unpacks section 9's parenthesised limit and deductible", () => { + const c = byRisk(p); + const theft = c["ELITE / ELITE PLUS — PARTIAL THEFT: EXCLUDED"]; + expect(theft?.insuredAmount).toBe(0); + expect(theft?.deductible).toBe("0.00 DLLS. POR EVENTO"); + expect(c["ELITE / ELITE PLUS — VANDALISM: EXCLUDED"]).toBeDefined(); + }); + + it("emits each coverage once even though the PDF prints the face twice", () => { + // The real upload is ORIGINAL + AGENT COPY + receipt + three travel + // cards, all concatenated into one string before parsing. + const doubled = page(ANA_AUTO_AMPLIA.text + "\n\n" + ANA_AUTO_AMPLIA.text); + expect(parsePolicy(doubled).coverages).toHaveLength(p.coverages.length); + expect(parsePolicy(doubled).vehicles).toHaveLength(1); + }); +}); + +describe("parsePolicy / ANA responsabilidad civil por días", () => { + const p = parsePolicy(ANA_AUTO_RC_DIAS); + + it("reads a by-the-day term rather than defaulting to a year", () => { + // Left at the schema's 365 default this weekend policy would sit in the + // renewals window a year out. + expect(p.policyFrom?.toISOString().slice(0, 10)).toBe("2026-07-23"); + expect(p.policyTo?.toISOString().slice(0, 10)).toBe("2026-07-26"); + expect(p.coveragePeriodDays).toBe(3); + }); + + it("reads the clave when the agent's phone occupies the left cell", () => { + // The by-the-day products print "(661) 612 12 55" ahead of the clave, so + // it is no longer the first thing on its line. + expect(p.notes.join(" | ")).toMatch(/clave de agente: 70175/); + }); + + it("marks the excluded sections as excluded, not as insured for zero", () => { + const risks = p.coverages.map((c) => c.risk); + expect(risks).toContain("MATERIAL DAMAGE — VEHICLE: EXCLUDED"); + expect(risks).toContain("TOTAL THEFT — TOWING: EXCLUDED"); + // The liability sections are what this product actually sells, and they + // are NOT excluded. + expect(risks).toContain("LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES"); + }); +}); + +describe("parsePolicy / ANA driver's policy (licencia)", () => { + const p = parsePolicy(ANA_LICENCIA); + + it("reads the holder off the numbered POLICY HOLDER list", () => { + expect(p.policyNumber).toBe("700489616"); + expect(p.insuredName).toBe("PAMELA DENISE WAGONER"); + expect(p.legalAddress).toBe("49305 HIGHWAY 74 SPC 10, PALM DESERT, CA, 92260"); + expect(p.zip).toBe("92260"); + }); + + it("lists one driver, not one per printed copy of the page", () => { + // The face renders three times in the real PDF; an unbounded walk + // returns the same person three times, which reads as a three-driver + // policy rather than as a parse bug. + const tripled = page([ANA_LICENCIA.text, ANA_LICENCIA.text, ANA_LICENCIA.text].join("\n\n")); + expect(p.drivers).toHaveLength(1); + expect(parsePolicy(tripled).drivers).toHaveLength(1); + }); + + it("drops the four empty driver slots", () => { + // Slots 2-5 print an empty NAME and a bare "NONE" licence. + expect(p.drivers.map((d) => d.fullName)).toEqual(["PAMELA DENISE WAGONER"]); + expect(p.drivers[0].licenseNumber).toBe("N0017668"); + expect(p.drivers[0].phone).toBe("3102001538"); + }); + + it("insures no vehicle", () => { + expect(p.vehicles).toEqual([]); + expect(p.notes.join(" | ")).toMatch(/no ampara un veh[íi]culo determinado/); + }); + + it("separates the SUM INSURED and PREMIUM columns by position", () => { + // Both columns print the same shape ("100,000.00 usd." / "18.70 usd.") + // and neither is labelled per row — only the offset tells them apart. + const c = byRisk(p); + const pd = c["LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES"]; + expect(pd?.insuredAmount).toBe(100000); + expect(pd?.premium).toBe(18.7); + }); + + it("reads the trailing Per Person / Per Accident labels on this layout", () => { + // They FOLLOW their amount here and PRECEDE it on the automobile face. + const c = byRisk(p); + expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.insuredAmount).toBe(100000); + expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.insuredAmount).toBe(200000); + expect(c["MEDICAL EXPENSES — POR PERSONA"]?.insuredAmount).toBe(4000); + expect(c["MEDICAL EXPENSES — POR EVENTO"]?.insuredAmount).toBe(20000); + }); + + it("charges a section's premium once, not once per limit", () => { + const c = byRisk(p); + expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.premium).toBe(54.27); + expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.premium).toBeNull(); + }); + + it("handles the section order this layout uses", () => { + // CATASTROPHIC LIABILITY prints ABOVE MEDICAL EXPENSES here and below it + // on the automobile face; blocks are keyed by where the labels land. + const c = byRisk(p); + expect(c["CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES"]?.insuredAmount).toBe(0); + expect(c["LEGAL AID"]?.premium).toBe(30); + expect(c["ROADSIDE ASSISTANCE"]?.premium).toBe(30); + }); + + it("carries the excluded-risk sentence that defines the product", () => { + expect(p.notes.join(" | ")).toMatch(/riesgos excluidos: Collision, overtuning/); + }); +}); diff --git a/apps/api/src/policy-ocr/parsers/policy-parser.ts b/apps/api/src/policy-ocr/parsers/policy-parser.ts index 1109303..7e12ff7 100644 --- a/apps/api/src/policy-ocr/parsers/policy-parser.ts +++ b/apps/api/src/policy-ocr/parsers/policy-parser.ts @@ -36,6 +36,25 @@ export interface ParsedPolicy { * coverages auditable on the Policy row. */ coverages: ParsedCoverage[]; + /** + * Printed term length in days. GMX prints "Term 12 months" and the office + * keys the default 365; ANA's tourist book sells 3- and 4-day policies and + * prints the count in its own `DAYS` cell, so leaving `Policy + * .coveragePeriodDays` at its 365 default would overstate a weekend policy + * by a year. + */ + coveragePeriodDays: number | null; + /** + * Vehicles listed on the policy face. Empty on the property lines (GMX + * Hogar) and on ANA's driver's policy, which insures a person rather than + * a car. + */ + vehicles: ParsedVehicle[]; + /** + * Named drivers. ANA's `DRIVER´S POLICY FOR AUTOMOBILE` prints up to five + * slots and carries no vehicle at all — the drivers ARE the risk. + */ + drivers: ParsedDriver[]; /** Human-readable trail of what was read, surfaced in the review queue. */ notes: string[]; } @@ -46,6 +65,34 @@ export interface ParsedCoverage { insuredAmount: number | null; deductible: string | null; lossParticipation: string | null; + /** + * The per-coverage premium, when the layout prints one. GMX never does; + * ANA prints a `PREMIUM` column for the add-on sections (legal aid, + * roadside assistance, catastrophic liability), where the printed figure + * is what the coverage COST, not what it pays out. Recording it as + * `insuredAmount` would read as a $40 sum insured on the review screen. + */ + premium?: number | null; +} + +/** One row of ANA's `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table. */ +export interface ParsedVehicle { + /** "VEHICLE" | "TRAILER" | "TOWING" — the printed item slot. */ + item: string; + modelYear: string | null; + make: string | null; + /** The printed BODY cell ("PACIFICA", "GENESIS SEDAN"). */ + bodyType: string | null; + vinNumber: string | null; + licensePlate: string | null; +} + +export interface ParsedDriver { + fullName: string; + licenseNumber: string | null; + address: string | null; + phone: string | null; + email: string | null; } // --- shared helpers --------------------------------------------------------- @@ -211,6 +258,10 @@ function currencyCode(raw: string | null | undefined): string | null { */ const BRAND: [string, RegExp][] = [ ["GMX", /\bGMX\b|Grupo\s*Mexicano\s*de\s*Seguros|gmx\.com\.mx|JUNTOS\s*EL\s*RIESGO\s*ES\s*MENOR/i], + [ + "ANA", + /A\.?N\.?A\.?\s*COMPA[ÑN][IÍ]A\s*DE\s*SEGUROS|anaseguros\.com\.mx|auto-insurance-ana\.com|une@anaseguros/i, + ], ]; const LAYOUT: [string, RegExp][] = [ @@ -218,6 +269,10 @@ const LAYOUT: [string, RegExp][] = [ "GMX", /Multiple\s*Policy|IMPUESTO\s*PREDIAL[\s\S]{0,80}EN\s*FECHA|Material\s*damages\s*Section|PVL\s*Hogar|ESPECIFICACI[ÓO]N\s*QUE\s*SE\s*ADHIERE/i, ], + [ + "ANA", + /SPECIAL\s*POLICY\s*FOR\s*TOURISTS|DRIVER[´'`’]?S\s*POLICY\s*FOR\s*AUTOMOBILE|Tarjeta\s*de\s*Identificaci[óo]n\s*de\s*viajero/i, + ], ]; export function detectPolicyProvider(text: string): string | null { @@ -233,38 +288,40 @@ export function detectPolicyProvider(text: string): string | null { const PARSERS: Record ParsedPolicy> = { GMX: parseGmx, + ANA: parseAna, }; -const EMPTY_COVERAGE: ParsedCoverage = { - risk: "", - insuredAmount: null, - deductible: null, - lossParticipation: null, -}; +/** Every field null / empty — the base each provider parser fills in. */ +function emptyParsedPolicy(provider: string): ParsedPolicy { + return { + provider, + policyNumber: null, + insuredName: null, + additionalInsured: null, + agentName: null, + legalAddress: null, + zip: null, + policyFrom: null, + policyTo: null, + policyDate: null, + currency: null, + netPremium: null, + policyFee: null, + brokerFee: null, + total: null, + premiumPayment: null, + coverages: [], + coveragePeriodDays: null, + vehicles: [], + drivers: [], + notes: [], + }; +} export function parsePolicy(page: OcrPage): ParsedPolicy { const provider = detectPolicyProvider(page.text); if (!provider) { - return { - provider: "", - policyNumber: null, - insuredName: null, - additionalInsured: null, - agentName: null, - legalAddress: null, - zip: null, - policyFrom: null, - policyTo: null, - policyDate: null, - currency: null, - netPremium: null, - policyFee: null, - brokerFee: null, - total: null, - premiumPayment: null, - coverages: [], - notes: ["no se reconoció el proveedor"], - }; + return { ...emptyParsedPolicy(""), notes: ["no se reconoció el proveedor"] }; } return PARSERS[provider](page); } @@ -399,7 +456,7 @@ function parseGmxCaratula(page: OcrPage): ParsedPolicy { } return { - provider: "GMX", + ...emptyParsedPolicy("GMX"), policyNumber: normalizePolicyNumber(policyNumber), insuredName, additionalInsured, @@ -410,16 +467,28 @@ function parseGmxCaratula(page: OcrPage): ParsedPolicy { policyTo, policyDate, currency, - netPremium: null, - policyFee: null, - brokerFee: null, - total: null, premiumPayment, coverages, + // Derived from the dates, or left null so `Policy.coveragePeriodDays` + // keeps its 365 default — which is the right answer for GMX, whose only + // printed term is "12 months". Converting that term to a day count here + // would just be a worse way of saying 365. + coveragePeriodDays: daysBetween(policyFrom, policyTo), notes, }; } +/** + * Whole days from `from` to `to`, or null when either is missing. Both are + * UTC midnights out of `parseDate`, so the division is exact — no DST hour to + * round away. + */ +function daysBetween(from: Date | null, to: Date | null): number | null { + if (!from || !to) return null; + const days = Math.round((to.getTime() - from.getTime()) / 86_400_000); + return days > 0 ? days : null; +} + /** * Read the value that follows a `LABEL` on the same line. Used by every * "Label Value" cell on the GMX header table — matches on the line @@ -615,22 +684,13 @@ function parseGmxEspecificacion(page: OcrPage): ParsedPolicy { ); return { - provider: "GMX", + ...emptyParsedPolicy("GMX"), policyNumber, insuredName, additionalInsured, - agentName: null, legalAddress, zip, - policyFrom: null, - policyTo: null, - policyDate: null, currency, - netPremium: null, - policyFee: null, - brokerFee: null, - total: null, - premiumPayment: null, coverages, notes, }; @@ -1023,4 +1083,891 @@ function espectCurrency(raw: string | null | undefined): string | null { if (s === "USD" || s.startsWith("DL") || s.startsWith("DLL")) return "USD"; if (s === "MN" || s === "MXN") return "MXN"; return null; +} + +// --- A.N.A. Seguros ---------------------------------------------------------- + +/** + * A.N.A. Compañía de Seguros — the tourist auto book the Rosarito office + * sells. Unlike GMX these are born-digital portal PDFs, so `pdftotext + * -layout` gives exact glyphs and exact columns; the parser leans on column + * geometry where the labels alone are ambiguous, which OCR'd scans would not + * allow. (The pipeline still runs Tesseract when a PDF has no text layer; + * the label-anchored paths below survive that, the column-geometry path on + * the driver's policy degrades to "amount read, column unknown".) + * + * ANA ships two unrelated faces from the same portal: + * + * 1. **AUTOMOBILE** (`SPECIAL POLICY FOR TOURISTS`) — insures a car. Carries + * the `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table and nine + * numbered risk sections in a single `LIMIT OF LIABILITY` column. + * 2. **DRIVER´S POLICY FOR AUTOMOBILE** (the office calls it *licencia*) — + * insures up to five named drivers whatever they happen to be driving. + * NO vehicle table at all, and the risk table has two value columns + * (`SUM INSURED` and `PREMIUM`) instead of one. + * + * The four AUTOMOBILE products the office sells (amplia / responsabilidad + * civil, annual / by-the-day) are the SAME layout with different numbers — + * "amplia" prints a non-zero vehicle value and COVERED on sections 1-2, + * "resp. civil" prints 0.00 and EXCLUDED. That is data, not a layout, so + * there is one AUTOMOBILE parser rather than four. + */ +function parseAna(page: OcrPage): ParsedPolicy { + const lines = page.text.split("\n"); + return isAnaDriverPolicy(page.text) + ? parseAnaDriverPolicy(lines) + : parseAnaAutomobile(lines); +} + +/** The driver's policy announces itself in the title band above the No. cell. */ +function isAnaDriverPolicy(text: string): boolean { + return /DRIVER[´'`’]?S\s+POLICY\s+FOR\s+AUTOMOBILE/i.test(text); +} + +/** + * The header band both ANA faces share: policy number, issuing agent, the + * three dates, the printed term in days, and the six-cell money row. + * + * Everything here is read off the FIRST copy in the merged text. Each ANA + * PDF renders the same face two or three times — `ORIGINAL`, `AGENT COPY`, + * a summary receipt, then three travel ID cards — and the pipeline + * concatenates every page into one string before parsing. First-match is + * therefore the right rule throughout, and the repeats are used only where + * they buy a cross-check (the policy number, below). + */ +interface AnaHeader { + policyNumber: string | null; + agentName: string | null; + agentCode: string | null; + policyDate: Date | null; + policyFrom: Date | null; + policyTo: Date | null; + coveragePeriodDays: number | null; + netPremium: number | null; + policyFee: number | null; + total: number | null; +} + +function parseAnaHeader(lines: string[], notes: string[]): AnaHeader { + const text = lines.join("\n"); + + // ----- policy number ----------------------------------------------------- + // Printed in the header cell (`No. 700489651`), again on the summary + // receipt (`Policy No:`), and once per travel ID card in both languages. + // Reading all of them is a free cross-check; disagreement means one read + // is wrong and there is no tie-breaker, so the row goes to a human. + // + // The two-space floor after `No.` is load-bearing: the agent's own street + // address on this form is "BENITO JUAREZ 25 No.50 INT 38", and a looser + // pattern reads the house number as the policy number. + const readings = [ + ...new Set([ + ...allMatches(text, /(?:^|\s)No\.\s{2,}(\d{6,12})(?=\s|$)/), + ...allMatches(text, /Policy\s+No\s*:\s*(\d{6,12})/i), + ...allMatches(text, /Policy\s+Number\s*:\s*(\d{6,12})/i), + ...allMatches(text, /No\.\s*de\s*p[óo]liza\s*:\s*(\d{6,12})/i), + ]), + ]; + const policyNumber = readings[0] ?? null; + if (!policyNumber) notes.push("no se pudo leer el número de póliza"); + if (readings.length > 1) { + notes.push(`número de póliza leído de ${readings.length} formas (${readings.join(", ")})`); + } + + // ----- issuing agent ----------------------------------------------------- + const agentName = anaAgentName(lines); + // The agent's clave sits in the cell under their address: on the same line + // as the `TO` date on the automobile face, alone behind a stray "." on the + // driver's policy. Both patterns are needed — the by-the-day products + // print the agent's phone in that line's left cell ("(661) 612 12 55"), so + // the clave is no longer the first thing on the line. + // + // What neither pattern can reach is the agent's own postal code + // ("ROSARITO, BAJA CALIFORNIA 22710"), which is the misread worth + // avoiding: it is five digits in the same header band. + const agentCode = firstMatch(text, [ + /\s{2,}(\d{4,6})\s{2,}TO\s+\d/, + /^\s*[.\s]*(\d{4,6})\s*$/m, + ]); + if (agentCode) notes.push(`clave de agente: ${agentCode}`); + + // ----- dates ------------------------------------------------------------- + // All three sit in DAY / MONTH / YEAR column cells, so they arrive as + // three space-separated runs rather than a delimited date. DD MM YYYY is + // confirmed by the travel card, which prints the same term twice — + // "Desde : 07/08/2026" in Spanish next to "From : 08/07/2026" in English. + const issued = text.match(/(? /ISSUED\s+BY\s*:/i.test(l)); + if (start < 0) return null; + for (let i = start + 1; i < lines.length && i < start + 6; i++) { + const candidate = lines[i] + .split(/DAY\s+MONTH\s+YEAR/i)[0] + .replace(/\bDAYS?\b/gi, "") + .replace(/\s+/g, " ") + .trim(); + // The "DAYS" header line collapses to empty; a date/clave cell to digits. + if (!candidate || /^[\d\s.,:-]+$/.test(candidate)) continue; + return candidate; + } + return null; +} + +/** The bare number in the `DAYS` cell, between `ISSUED BY:` and the money row. */ +function anaPrintedDays(lines: string[]): number | null { + const start = lines.findIndex((l) => /ISSUED\s+BY\s*:/i.test(l)); + if (start < 0) return null; + for (let i = start + 1; i < lines.length && i < start + 14; i++) { + if (/\bDISCOUNT\b/i.test(lines[i])) break; + const m = lines[i].match(/^\s*(\d{1,3})\s*$/); + if (m) return Number(m[1]); + } + return null; +} + +/** + * The six-cell money row under `DISCOUNT | PREMIUM | POLICY FEE | TAX | + * LOCAL TAX | TOTAL`. + * + * Read positionally off the header, not by label: the cells carry nothing + * but numbers, and an unused DISCOUNT prints as a bare "-" rather than 0.00, + * so a "find the six amounts" approach would shift every value one column + * left on a discounted policy. Requiring exactly six whitespace-separated + * cells is the guard — a row that doesn't have them is reported as unread + * instead of silently mis-mapped. + */ +function anaMoneyRow(lines: string[]): { + discount: number | null; + netPremium: number | null; + policyFee: number | null; + tax: number | null; + localTax: number | null; + total: number | null; +} | null { + const header = lines.findIndex((l) => + /^DISCOUNT\s+PREMIUM\s+POLICY\s+FEE\s+TAX\s+LOCAL\s+TAX\s+TOTAL$/i.test( + l.trim().replace(/\s+/g, " "), + ), + ); + if (header < 0) return null; + + for (let i = header + 1; i < lines.length && i < header + 4; i++) { + const cells = lines[i].trim().split(/\s+/); + if (cells.length !== 6) continue; + const cell = (c: string) => (/\d/.test(c) ? money(c) : null); + return { + discount: cell(cells[0]), + netPremium: cell(cells[1]), + policyFee: cell(cells[2]), + tax: cell(cells[3]), + localTax: cell(cells[4]), + total: cell(cells[5]), + }; + } + return null; +} + +/** + * ANA quotes its tourist book in dollars and prints the currency next to + * every figure — `DLLS.` on the automobile face, `usd.` on the driver's + * policy. Checked in that order because the MXN branch is deliberately + * loose and would otherwise claim a page over an incidental "M.N.". + */ +function anaCurrency(text: string, notes: string[]): string | null { + if (/\bDLLS?\.|\busd\.|\bUSD\b|AMERICAN\s+DOLLARS/i.test(text)) return "USD"; + if (/\bMXN\b|\bPESOS\b|\bM\.\s?N\.\b/i.test(text)) return "MXN"; + notes.push("no se pudo determinar la moneda"); + return null; +} + +/** + * A `LABEL value` cell on ANA's insured block. The value ends where the + * next cell's label begins — the block is three columns wide and + * `pdftotext` renders all three on one line, so an unbounded read of + * `ADDRESS` swallows "EMAIL PROLABSALE@AOL.COM". + */ +function anaLabelValue(lines: string[], label: RegExp): string | null { + for (const line of lines) { + const m = line.match(label); + if (!m?.[1]) continue; + const value = m[1] + .replace(/\s{2,}(LICENSE|EMAIL|TELEPHONE|PAYMENT\s+DEADLINE)\b.*$/i, "") + .replace(/\s+/g, " ") + .trim(); + if (value) return value; + } + return null; +} + +// --- ANA / AUTOMOBILE -------------------------------------------------------- + +/** + * ANA's `SPECIAL POLICY FOR TOURISTS` automobile face. + * + * Layout, top to bottom: the ANA letterhead and claim phone numbers, the + * `No.` cell, the issuing-agent / dates / term header band, the six-cell + * money row, the insured's contact block, the vehicle table, and the nine + * numbered risk sections. + */ +function parseAnaAutomobile(lines: string[]): ParsedPolicy { + const text = lines.join("\n"); + const notes: string[] = []; + const header = parseAnaHeader(lines, notes); + + // ----- insured contact block -------------------------------------------- + const insuredName = anaLabelValue(lines, /^\s*INSURED\s{2,}(\S.*)$/); + const street = anaLabelValue(lines, /^\s*ADDRESS\s{2,}(\S.*)$/); + const cityState = anaLabelValue(lines, /^\s*CITY\s*&\s*STATE\s{2,}(\S.*)$/); + const legalAddress = [street, cityState].filter(Boolean).join(", ") || null; + + // The ZIP sits in its own column between the city/state cell and the + // TELEPHONE cell, so it comes back glued to the city value after the + // whitespace collapse. Take the last five-digit run — a US street number + // ("10308 DONNA AVE") is in the ADDRESS cell, not this one, but anchoring + // on the first match would still be the fragile choice. + const zip = cityState?.match(/(\d{5})(?!.*\d{5})/)?.[1] ?? null; + + const license = firstMatch(text, [/\bLICENSE\s{2,}([A-Z0-9]{4,20})\b/i]); + const email = firstMatch(text, [/\bEMAIL\s{2,}(\S+@\S+)/i]); + const phone = firstMatch(text, [/\bTELEPHONE\s+([\d()\s.-]{7,20})/i])?.trim() ?? null; + + // The insured on this face is also the driver, and their US license number + // is the only identifier ANA prints. One InsuredDriver row keeps it out of + // free text where nothing can query it. + const drivers: ParsedDriver[] = insuredName + ? [{ fullName: insuredName, licenseNumber: license, address: legalAddress, phone, email }] + : []; + + if (!insuredName) notes.push("no se pudo leer el nombre del asegurado"); + + // ----- vehicle table ----------------------------------------------------- + const vehicles = parseAnaVehicles(lines, notes); + + // ----- risk sections ----------------------------------------------------- + const region = anaRiskRegion(lines); + const coverages = parseAnaAutoCoverages(region, notes); + if (vehicles.length > 1) { + notes.push( + "las sumas VEHICLE/TRAILER/TOWING son los valores declarados de cada " + + "unidad, impresos a lo ancho de las secciones 1 y 2", + ); + } + + // The `PAYMENT DEADLINE` cell prints its value on the line below the + // label, in the same column — "IMMEDIATE" for everything the office sells + // through the portal. Mapped onto `premiumPayment`, which is the same + // "when is it due" field GMX fills with "CONTADO". + const paymentDeadline = /^\s*PAYMENT\s+DEADLINE\s*$/m.test(text) + ? firstMatch(text, [/^\s*(IMMEDIATE|INMEDIATO)\s*$/im]) + : null; + + return { + ...emptyParsedPolicy("ANA"), + policyNumber: header.policyNumber, + insuredName, + agentName: header.agentName, + legalAddress, + zip, + policyFrom: header.policyFrom, + policyTo: header.policyTo, + policyDate: header.policyDate, + currency: anaCurrency(text, notes), + netPremium: header.netPremium, + policyFee: header.policyFee, + total: header.total, + premiumPayment: paymentDeadline, + coverages, + coveragePeriodDays: header.coveragePeriodDays, + vehicles, + drivers, + notes, + }; +} + +/** + * The `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table — three fixed + * slots (VEHICLE, TRAILER, TOWING), of which the tourist policies the office + * writes fill only the first. Unused slots print a "." per cell. + * + * Parsed by token role rather than by column offset, because the BODY cell + * is the one that wraps: "PACIFICA" is one token and "GENESIS SEDAN" is two, + * so a fixed token count reads the VIN out of the wrong slot on the second. + * The VIN is the anchor — 17 characters is a shape nothing else in the row + * has — and BODY is whatever sits between the make and it. + */ +function parseAnaVehicles(lines: string[], notes: string[]): ParsedVehicle[] { + const header = lines.findIndex((l) => + /^ITEM\s+YEAR\s+MAKE\s+BODY\s+SERIAL\s+No\.\s+PLATES$/i.test(l.trim().replace(/\s+/g, " ")), + ); + if (header < 0) return []; + + const out: ParsedVehicle[] = []; + for (let i = header + 1; i < lines.length && i < header + 5; i++) { + const m = lines[i].trim().match(/^(VEHICLE|TRAILER|TOWING)\b(.*)$/i); + if (!m) break; + const tokens = m[2].trim().split(/\s+/).filter((t) => t && t !== "."); + if (tokens.length === 0) continue; + + const modelYear = /^\d{4}$/.test(tokens[0]) ? tokens.shift()! : null; + const vinAt = tokens.findIndex((t) => /^[A-Z0-9]{17}$/i.test(t)); + const make = tokens[0] ?? null; + const bodyType = + (vinAt > 0 ? tokens.slice(1, vinAt) : tokens.slice(1, -1)).join(" ") || null; + const vinNumber = vinAt >= 0 ? tokens[vinAt] : null; + // With no VIN to anchor on, the last token is the plate — unless it is + // the only token, in which case it is the make and there is no plate. + const plate = + vinAt >= 0 + ? tokens.slice(vinAt + 1).join(" ") + : tokens.length > 1 + ? tokens[tokens.length - 1] + : ""; + + out.push({ + item: m[1].toUpperCase(), + modelYear, + make, + bodyType, + vinNumber, + licensePlate: plate || null, + }); + if (!vinNumber) notes.push(`sin número de serie en la unidad ${m[1].toUpperCase()}`); + } + return out; +} + +/** + * The risk table, bounded to the FIRST copy of the face. + * + * Every ANA PDF prints the whole page twice (ORIGINAL, then AGENT COPY) and + * the pipeline hands the parser both concatenated. Without the end bound the + * section walk runs straight into the second copy and every coverage is + * emitted twice. + */ +function anaRiskRegion(lines: string[]): string[] { + const start = lines.findIndex((l) => /SPECIFICATION\s+OF\s+RISKS/i.test(l)); + if (start < 0) return []; + for (let i = start + 1; i < lines.length; i++) { + if ( + /^\s*ISSUED\s+ONLINE\s*$/i.test(lines[i]) || + /The\s+following\s+risks\s+are\s+excluded/i.test(lines[i]) || + /HEREINAFTER\s+CALLED/i.test(lines[i]) + ) { + return lines.slice(start, i); + } + } + return lines.slice(start); +} + +type AnaSectionKind = "vehicleValue" | "limit" | "perPersonAccident" | "addon" | "elite"; + +/** + * The printed risk sections, as a fixed table rather than a generic row + * regex: this is a pre-printed insurer form whose nine sections never change + * name, and each one puts its numbers in a different place. Section 3 has a + * single limit; 4 and 5 split per-person / per-accident; 6, 7 and 8 print a + * PREMIUM (what it cost) where the others print a limit (what it pays); 1 + * and 2 carry the declared VEHICLE / TRAILER / TOWING values in a column + * shared across both. + * + * The driver's policy reuses the same table minus the vehicle sections and + * renames two — `LEGAL AID` without the `A.N.A.'s` prefix, and `AUTOMOBILE + * ASSISTANCE` for what the automobile face calls `ROADSIDE ASSISTANCE`. + * Same coverage, so it gets the same canonical name. + */ +const ANA_SECTIONS: { key: RegExp; name: string; kind: AnaSectionKind }[] = [ + { key: /^MATERIAL\s+DAMAGE\b/i, name: "MATERIAL DAMAGE", kind: "vehicleValue" }, + { key: /^TOTAL\s+THEFT\b/i, name: "TOTAL THEFT", kind: "vehicleValue" }, + { + key: /^LIABILITY\s+FOR\s+PROPERTY\s+DAMAGE\s+TO\s+THIRD\s+PARTIES\b/i, + name: "LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES", + kind: "limit", + }, + { + key: /^BODILY\s+INJURY\s+LIABILITY\b/i, + name: "BODILY INJURY LIABILITY", + kind: "perPersonAccident", + }, + { key: /^MEDICAL\s+EXPENSES\b/i, name: "MEDICAL EXPENSES", kind: "perPersonAccident" }, + { + key: /^CATASTROPHIC\s+LIABILITY\s+FOR\s+DEATH\s+OF\s+THIRD\b/i, + name: "CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES", + kind: "addon", + }, + { key: /^(?:A\.N\.A\.[´'`’]?s\s+)?LEGAL\s+AID\b/i, name: "LEGAL AID", kind: "addon" }, + { + key: /^(?:A\.N\.A\.[´'`’]?s\s+)?(?:ROADSIDE|AUTOMOBILE)\s+ASSISTANCE\b/i, + name: "ROADSIDE ASSISTANCE", + kind: "addon", + }, + { key: /^ELITE\s+OR\s+ELITE\s+PLUS\b/i, name: "ELITE / ELITE PLUS", kind: "elite" }, +]; + +interface AnaBlock { + name: string; + kind: AnaSectionKind; + lines: string[]; +} + +/** + * Split the risk region into one block per section: from a section's label + * line to the next section's. + * + * Blocks are keyed by where the labels actually appear, not by the order of + * `ANA_SECTIONS` — the driver's policy prints CATASTROPHIC LIABILITY *above* + * MEDICAL EXPENSES while the automobile face prints it below, and a walk + * that assumed the declared order would hand one section the other's + * numbers. + * + * The leading `\d+` strip is the section number, which prints in its own + * narrow column and lands at the head of the line for the sections whose + * label wraps (`9 PARTIAL THEFT (LIMIT …`). + */ +function anaSectionBlocks(region: string[]): AnaBlock[] { + const hits: { at: number; name: string; kind: AnaSectionKind }[] = []; + region.forEach((raw, i) => { + const line = raw.trim().replace(/^\d{1,2}\s+/, ""); + const hit = ANA_SECTIONS.find((s) => s.key.test(line)); + if (hit) hits.push({ at: i, name: hit.name, kind: hit.kind }); + }); + return hits.map((h, n) => ({ + name: h.name, + kind: h.kind, + lines: region.slice(h.at, hits[n + 1]?.at ?? region.length), + })); +} + +/** + * Is the section covered or excluded? + * + * `COVERED/EXCLUDED` is the column *header* and prints on every section + * regardless — it has to be removed before the check, or every section reads + * as EXCLUDED. What remains is the single word printed in that column. + */ +function anaStatus(block: string[]): "COVERED" | "EXCLUDED" | null { + const t = block.join("\n").replace(/COVERED\s*\/\s*EXCLUDED/gi, " "); + if (/\bEXCLUDED\b/i.test(t)) return "EXCLUDED"; + if (/\bCOVERED\b/i.test(t)) return "COVERED"; + return null; +} + +/** + * The `DEDUCTIBLE:` sentence, which wraps across the rest of the section's + * block with the right-hand value columns interleaved on the same lines. + * Those columns are stripped per line before the join, or the deductible + * comes back as "…ON AUTOS COVERED (SEDANS…) 0.00 DLLS.". + */ +function anaDeductibleText(block: string[]): string | null { + const start = block.findIndex((l) => /DEDUCTIBLE\s*:/i.test(l)); + if (start < 0) return null; + const parts: string[] = []; + for (let i = start; i < block.length; i++) { + const stripped = block[i] + .replace(/\s{2,}[\d,]+\.\d{2}\s*(?:DLLS?\.|usd\.)?\s*$/i, "") + .replace(/\s{2,}(?:COVERED|EXCLUDED|VEHICLE|TRAILER|TOWING)\s*$/i, "") + .replace(/^\s*\d{1,2}\s+/, "") + .trim(); + if (!stripped || /^(COVERED|EXCLUDED|VEHICLE|TRAILER|TOWING)$/i.test(stripped)) continue; + parts.push(stripped); + } + const joined = parts.join(" ").replace(/^DEDUCTIBLE\s*:\s*/i, "").replace(/\s+/g, " ").trim(); + return joined || null; +} + +/** + * Coverages off the automobile face's single `LIMIT OF LIABILITY` column. + * + * An excluded section is recorded in the risk label rather than as a zero + * amount — same rule as the GMX especificación parser. "Excluded" and + * "insured for $0.00" print the same number here (a responsabilidad-civil + * policy shows 0.00 for material damage) and mean very different things, and + * `ParsedCoverage` has no field for the distinction. + */ +function parseAnaAutoCoverages(region: string[], notes: string[]): ParsedCoverage[] { + const out: ParsedCoverage[] = []; + + for (const block of anaSectionBlocks(region)) { + const text = block.lines.join("\n"); + const status = anaStatus(block.lines); + const label = (sub?: string | null) => + `${block.name}${sub ? ` — ${sub}` : ""}${status === "EXCLUDED" ? ": EXCLUDED" : ""}`; + + switch (block.kind) { + case "vehicleValue": { + const deductible = anaDeductibleText(block.lines); + // The item keyword and its value are on different lines (the + // keyword heads the column, the amount sits a line or two below), + // so one pass over the whole block tracks whichever keyword was + // seen most recently. Only amounts suffixed `DLLS.` count as + // values — the "$500.00 ON AUTOS" inside the deductible sentence + // is not one. + const re = /\b(VEHICLE|TRAILER|TOWING)\b|([\d,]+\.\d{2})\s*DLLS?\./gi; + let item: string | null = null; + let found = false; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + if (m[1]) { + item = m[1].toUpperCase(); + continue; + } + found = true; + out.push({ + risk: label(item), + insuredAmount: money(m[2]), + deductible, + lossParticipation: null, + premium: null, + }); + } + if (!found) { + out.push({ + risk: label(), + insuredAmount: null, + deductible, + lossParticipation: null, + premium: null, + }); + notes.push(`sin suma asegurada en "${block.name}"`); + } + break; + } + + case "limit": { + const m = text.match(/([\d,]+\.\d{2})\s*DLLS?\./i); + if (!m) notes.push(`sin límite en "${block.name}"`); + out.push({ + risk: label(), + insuredAmount: m ? money(m[1]) : null, + deductible: null, + lossParticipation: null, + premium: null, + }); + break; + } + + case "perPersonAccident": { + // The two limits are labelled by the tail of the wrapped column + // header ("PER" on the label line, "PERSON"/"ACCIDENT" on the + // value line), so the value-line keyword is the reliable anchor. + const person = text.match(/\bPERSON\s+([\d,]+\.\d{2})/i); + const accident = text.match(/\bACCIDENT\s+([\d,]+\.\d{2})/i); + if (!person && !accident) notes.push(`sin límites en "${block.name}"`); + if (person) { + out.push({ + risk: label("POR PERSONA"), + insuredAmount: money(person[1]), + deductible: null, + lossParticipation: null, + premium: null, + }); + } + if (accident) { + out.push({ + risk: label("POR EVENTO"), + insuredAmount: money(accident[1]), + deductible: null, + lossParticipation: null, + premium: null, + }); + } + break; + } + + case "addon": { + // These three print a PREMIUM, not a limit: the last amount in the + // block is what the coverage cost. Recorded as `premium` so the + // review screen never shows $40 as a sum insured. + const amounts = [...text.matchAll(/([\d,]+\.\d{2})/g)]; + out.push({ + risk: label(), + insuredAmount: null, + deductible: null, + lossParticipation: null, + premium: amounts.length ? money(amounts[amounts.length - 1][1]) : null, + }); + break; + } + + case "elite": { + // Section 9 packs both its coverages into parenthesised prose: + // "PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER + // EVENT)" — no space after "DLLS." in the source. One COVERED / + // EXCLUDED applies to the whole section. + let matched = false; + for (const line of block.lines) { + const m = line.match( + /\b(PARTIAL\s+THEFT|VANDALISM)\s*\(\s*LIMIT\s*([\d,]+\.\d{2})\s*DLLS?\.\s*WITH\s+DEDUCTIBLE\s*:\s*([\d,]+\.\d{2})\s*DLLS?\.[^)]*\)(.*)$/i, + ); + if (!m) continue; + matched = true; + const trailing = m[4].match(/([\d,]+\.\d{2})/); + out.push({ + risk: label(m[1].toUpperCase().replace(/\s+/g, " ")), + insuredAmount: money(m[2]), + deductible: `${m[3]} DLLS. POR EVENTO`, + lossParticipation: null, + premium: trailing ? money(trailing[1]) : null, + }); + } + if (!matched) notes.push(`no se pudo desglosar "${block.name}"`); + break; + } + } + } + + if (out.length === 0) notes.push("no se encontraron coberturas"); + return out; +} + +// --- ANA / DRIVER´S POLICY (licencia) ---------------------------------------- + +/** + * ANA's `DRIVER´S POLICY FOR AUTOMOBILE` — the office's *licencia* product. + * It insures up to five named drivers in whatever car they are driving, so + * there is no vehicle table and the liability limits are the whole contract. + * + * The header band is the shared one (same `No.`, `ISSUED BY:` and money-row + * cells as the automobile face, a line or two out of alignment); the insured + * arrives as a numbered `POLICY HOLDER` list instead of a single INSURED + * cell; and the risk table gains a second value column. + */ +function parseAnaDriverPolicy(lines: string[]): ParsedPolicy { + const text = lines.join("\n"); + const notes: string[] = []; + const header = parseAnaHeader(lines, notes); + + const drivers = parseAnaDrivers(lines); + if (drivers.length === 0) notes.push("no se pudo leer ningún conductor"); + if (drivers.length > 1) notes.push(`${drivers.length} conductores nombrados`); + + // Driver 1 is the policy holder; the platform's single insured-name field + // takes them and the rest ride on the InsuredDriver rows. + const holder = drivers[0] ?? null; + const legalAddress = holder?.address ?? null; + const zip = legalAddress?.match(/(\d{5})(?!.*\d{5})/)?.[1] ?? null; + + const region = anaRiskRegion(lines); + const coverages = parseAnaDriverCoverages(region, notes); + + // The excluded-risk sentence is the difference between this product and a + // full automobile policy, so it is surfaced rather than dropped. + const excluded = firstMatch(text, [ + /The\s+following\s+risks\s+are\s+excluded\s+(.+?)\s*$/im, + ]); + if (excluded) notes.push(`riesgos excluidos: ${excluded.replace(/\s+/g, " ")}`); + notes.push("póliza de conductor: no ampara un vehículo determinado"); + + return { + ...emptyParsedPolicy("ANA"), + policyNumber: header.policyNumber, + insuredName: holder?.fullName ?? null, + agentName: header.agentName, + legalAddress, + zip, + policyFrom: header.policyFrom, + policyTo: header.policyTo, + policyDate: header.policyDate, + currency: anaCurrency(text, notes), + netPremium: header.netPremium, + policyFee: header.policyFee, + total: header.total, + coverages, + coveragePeriodDays: header.coveragePeriodDays, + drivers, + notes, + }; +} + +/** + * The `POLICY HOLDER` list: five numbered slots of NAME / ADDRESS / DRIVER + * LICENSE, of which the unused ones print an empty value and a bare "NONE" + * under the licence label. Slots without a name are dropped — a driver with + * no name is not a driver. + * + * The phone rides at the tail of the name cell ("PAMELA DENISE WAGONER + * Ph.3102001538") rather than in a column of its own. + * + * Bounded to the FIRST `POLICY HOLDER` list in the merged text. The driver's + * policy renders its face three times (ORIGINAL, AGENT COPY, INSURED COPY) + * and an unbounded walk returns the same person once per copy — which reads + * as a three-driver policy, not as a parse bug, so nothing downstream would + * have caught it. + */ +function parseAnaDrivers(lines: string[]): ParsedDriver[] { + const listAt = lines.findIndex((l) => /^\s*POLICY\s+HOLDER\s*$/i.test(l)); + if (listAt < 0) return []; + const nextList = lines.findIndex( + (l, i) => i > listAt && /^\s*POLICY\s+HOLDER\s*$/i.test(l), + ); + const scope = lines.slice(listAt, nextList < 0 ? lines.length : nextList); + + const email = firstMatch(lines.join("\n"), [/\bEMAIL\s{2,}(\S+@\S+)/i]); + const out: ParsedDriver[] = []; + + const starts: number[] = []; + scope.forEach((l, i) => { + if (/^\s*\d\.\s*NAME\s*:/i.test(l)) starts.push(i); + }); + + starts.forEach((at, n) => { + const end = starts[n + 1] ?? Math.min(at + 6, scope.length); + const raw = scope[at].match(/^\s*\d\.\s*NAME\s*:\s*(.*)$/i)?.[1]?.trim() ?? ""; + if (!raw) return; + + const phoneAt = raw.match(/\s{2,}Ph\.?\s*([\d()\s.-]{7,})\s*$/i); + const fullName = (phoneAt ? raw.slice(0, phoneAt.index) : raw).replace(/\s+/g, " ").trim(); + if (!fullName) return; + + const block = scope.slice(at, end); + const address = anaDriverField(block, /^\s*ADDRESS\s*:\s*(\S.*)$/i); + const licenseNumber = anaDriverField(block, /^\s*DRIVER\s+LICENSE\s*:\s*(\S.*)$/i); + + out.push({ + fullName, + licenseNumber, + address, + phone: phoneAt?.[1]?.trim() ?? null, + // ANA prints one contact email per policy, in the header cell above + // the list — not per driver. Attaching it to every driver would + // invent a fact, so only the holder gets it. + email: n === 0 ? email : null, + }); + }); + + return out; +} + +/** A `LABEL : value` cell inside one driver slot; "NONE" means empty. */ +function anaDriverField(block: string[], label: RegExp): string | null { + for (const line of block) { + const m = line.match(label); + if (!m?.[1]) continue; + const value = m[1].replace(/\s+/g, " ").replace(/,\s*$/, "").trim(); + if (!value || /^NONE$/i.test(value)) return null; + return value; + } + return null; +} + +/** + * Coverages off the driver's policy, which prints `SUM INSURED` and + * `PREMIUM` as two separate value columns. + * + * Both columns hold the same shape (`100,000.00 usd.` / `18.70 usd.`) and + * neither carries a label on its own line, so the only thing that + * distinguishes them is horizontal position. The split is taken from the + * header's own column offsets rather than a hardcoded number, because the + * offsets shift between ANA's products. + * + * When the header offsets can't be read — a scan OCR'd without column + * fidelity — every amount is reported as a sum insured and the reviewer is + * told the split failed, rather than half the premiums being silently filed + * as coverage limits. + */ +function parseAnaDriverCoverages(region: string[], notes: string[]): ParsedCoverage[] { + const head = region[0] ?? ""; + const sumCol = head.search(/SUM\s+INSURED/i); + const premiumCol = head.search(/PREMIUM/i); + const split = + sumCol >= 0 && premiumCol > sumCol ? Math.round((sumCol + premiumCol) / 2) : null; + if (split == null) { + notes.push("no se pudieron separar las columnas SUMA ASEGURADA / PRIMA"); + } + + const out: ParsedCoverage[] = []; + for (const block of anaSectionBlocks(region)) { + const status = anaStatus(block.lines); + const label = (sub?: string | null) => + `${block.name}${sub ? ` — ${sub}` : ""}${status === "EXCLUDED" ? ": EXCLUDED" : ""}`; + + const sums: { amount: number | null; sub: string | null }[] = []; + let premium: number | null = null; + + for (const line of block.lines) { + for (const m of line.matchAll(/([\d,]+\.\d{2})/g)) { + const at = m.index ?? 0; + // "Per Person" / "Per Accident" trail their amount on this layout + // (they precede it on the automobile face). + const after = line.slice(at + m[0].length, at + m[0].length + 24); + const sub = /Per\s+Person/i.test(after) + ? "POR PERSONA" + : /Per\s+Accident/i.test(after) + ? "POR EVENTO" + : null; + if (split != null && at >= split) premium = money(m[1]); + else sums.push({ amount: money(m[1]), sub }); + } + } + + if (sums.length === 0) { + out.push({ + risk: label(), + insuredAmount: null, + deductible: null, + lossParticipation: null, + premium, + }); + continue; + } + sums.forEach((s, i) => { + out.push({ + risk: label(s.sub), + insuredAmount: s.amount, + deductible: null, + lossParticipation: null, + // The premium covers the section, not each of its limits — putting + // it on every row would double it in any total the UI computes. + premium: i === 0 ? premium : null, + }); + }); + } + + if (out.length === 0) notes.push("no se encontraron coberturas"); + return out; } \ No newline at end of file diff --git a/apps/api/src/policy-ocr/policy-ocr.dto.ts b/apps/api/src/policy-ocr/policy-ocr.dto.ts index 3dc82ab..86259e0 100644 --- a/apps/api/src/policy-ocr/policy-ocr.dto.ts +++ b/apps/api/src/policy-ocr/policy-ocr.dto.ts @@ -3,10 +3,13 @@ import { IsArray, IsDateString, IsEnum, + IsInt, IsNumber, IsObject, IsOptional, IsString, + Max, + Min, MinLength, ValidateNested, } from "class-validator"; @@ -37,6 +40,9 @@ export class ConfirmPolicyDocumentDto { @IsOptional() @IsNumber() brokerFee?: number; @IsOptional() @IsNumber() total?: number; @IsOptional() @IsString() premiumPayment?: string; + /** Printed term in days. Omitted leaves the parsed value (or the schema's + * 365 default) in place; ANA sells 3- and 4-day tourist policies. */ + @IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number; /** Coverages parsed off the PDF, passed through verbatim to Policy.coveragesJson. */ @IsOptional() @IsObject() coveragesJson?: unknown; @@ -70,6 +76,7 @@ export class ReviewPolicyDocumentDto { @IsOptional() @IsNumber() brokerFee?: number; @IsOptional() @IsNumber() total?: number; @IsOptional() @IsString() premiumPayment?: string; + @IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number; @IsOptional() @IsObject() coveragesJson?: unknown; /** Set by the reviewer when the document matched an existing Policy. */ diff --git a/apps/api/src/policy-ocr/policy-ocr.service.ts b/apps/api/src/policy-ocr/policy-ocr.service.ts index 6a4718e..513e28a 100644 --- a/apps/api/src/policy-ocr/policy-ocr.service.ts +++ b/apps/api/src/policy-ocr/policy-ocr.service.ts @@ -10,7 +10,7 @@ import { PrismaService } from "../prisma/prisma.service"; import { StorageService } from "../storage/storage.service"; import type { UploadedFileLike } from "../storage/upload-file"; import { OCR_PROVIDER, type OcrPage, type OcrProvider } from "../statements/ocr/ocr.provider"; -import { parsePolicy } from "./parsers/policy-parser"; +import { parsePolicy, type ParsedDriver, type ParsedVehicle } from "./parsers/policy-parser"; import { PolicyMatcherService } from "./policy-matcher.service"; import type { ConfirmPolicyBatchDto, @@ -69,8 +69,11 @@ export class PolicyOcrService { ); } + // The provider is not asked of the uploader and not assumed: `process` + // sets it from what the parsers actually claimed, so the batch label can + // never contradict its own documents. Until then it says so. const batch = await this.prisma.policyOcrBatch.create({ - data: { provider: "GMX", uploadedById, label, fileCount: files.length }, + data: { provider: "por detectar", uploadedById, label, fileCount: files.length }, }); const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname })); @@ -112,6 +115,7 @@ export class PolicyOcrService { let fileOrdinal = 0; let globalPageOrdinal = 0; + const providersSeen = new Set(); for (const file of files) { fileOrdinal += 1; const sourceKey = `policy-ocr/${batchId}/source-${fileOrdinal}.pdf`; @@ -155,6 +159,7 @@ export class PolicyOcrService { if (parsed.provider === "") { throw new Error("no se reconoció el proveedor"); } + providersSeen.add(parsed.provider); const match = await this.matcher.match(parsed); const notes = [...parsed.notes, match.note].filter(Boolean); // Confident when exactly one Policy carries the printed number — @@ -193,12 +198,19 @@ export class PolicyOcrService { ? (parsed.coverages as unknown as Prisma.InputJsonValue) : Prisma.DbNull, extractedPremiumPayment: parsed.premiumPayment, + extractedCoveragePeriodDays: parsed.coveragePeriodDays, + extractedVehiclesJson: parsed.vehicles.length + ? (parsed.vehicles as unknown as Prisma.InputJsonValue) + : Prisma.DbNull, + extractedDriversJson: parsed.drivers.length + ? (parsed.drivers as unknown as Prisma.InputJsonValue) + : Prisma.DbNull, matchedPolicyId: match.policyId, matchedCustomerId: match.customerId, matchCandidates: match.candidates.length ? (match.candidates as unknown as Prisma.InputJsonValue) : Prisma.DbNull, - matchNote: notes.join("; ").slice(0, 190), + matchNote: notes.join("; "), }, }); } catch (err) { @@ -211,7 +223,7 @@ export class PolicyOcrService { pageNumber: fileOrdinal, storageKey: sourceKey, status: "OCR_FAILED", - matchNote: (err as Error).message.slice(0, 190), + matchNote: (err as Error).message, }, }); } @@ -219,7 +231,13 @@ export class PolicyOcrService { await this.prisma.policyOcrBatch.update({ where: { id: batchId }, - data: { status: "READY_FOR_REVIEW" }, + data: { + status: "READY_FOR_REVIEW", + // Whatever the parsers claimed. A mixed upload is labelled as mixed + // rather than as whichever provider happened to come first — the + // review header is the only place staff see what they dropped in. + provider: [...providersSeen].sort().join(" + ") || "desconocido", + }, }); } @@ -349,6 +367,7 @@ export class PolicyOcrService { ? (dto.coveragesJson as Prisma.InputJsonValue) : undefined, extractedPremiumPayment: dto.premiumPayment ?? undefined, + extractedCoveragePeriodDays: dto.coveragePeriodDays ?? undefined, matchedPolicyId, matchedCustomerId, status: dto.forceConfirm ? "CONFIRMED" : "MATCHED", @@ -474,14 +493,18 @@ export class PolicyOcrService { policyId = created.id; } - // 2. Attach the source PDF as a PolicyDocument. `doc.storageKey` + // 2. Vehicles and named drivers, for the providers whose face carries + // them (ANA's automobile and driver's policies; never GMX Hogar). + await this.applyVehiclesAndDrivers(doc, policyId); + + // 3. Attach the source PDF as a PolicyDocument. `doc.storageKey` // already points at the exact upload (`policy-ocr/{batchId}/source-N.pdf`) // so the attach is just a stream copy into the policy's namespace — // the previous per-page "which file did this page come from" walk is // gone because one PDF = one doc now. - await this.attachSourcePdf(doc.storageKey, policyId); + await this.attachSourcePdf(doc.storageKey, policyId, doc.provider); - // 3. Optionally post the premium to the ledger. Only when staff + // 4. Optionally post the premium to the ledger. Only when staff // explicitly asked (`postPremium` true) and netPremium parses — without // that gate a missing premium would silently book $0. let postedTransactionId: string | null = null; @@ -539,13 +562,104 @@ export class PolicyOcrService { }; } + /** + * Write the parsed `Vehicle` and `InsuredDriver` rows onto the policy. + * + * Both inserts are skipped when an equivalent row is already on the policy. + * The reason is `confirmBatch` applying to an EXISTING policy: the office + * uploads a renewal for a car already on file, and a blind insert would + * leave the customer with the same VIN listed twice with no way to tell + * which row the renewal belongs to. Matching is on the identifier the + * document actually prints — the VIN for a vehicle (falling back to the + * plate, since ANA's TRAILER/TOWING slots have no VIN), the licence number + * for a driver (falling back to the name). + * + * Nothing is ever updated or deleted here. A vehicle whose plate changed + * lands as a second row for a human to reconcile, which is the safe half + * of the mistake: an over-write would destroy the only record of what was + * insured last term. + */ + private async applyVehiclesAndDrivers( + doc: { extractedVehiclesJson: Prisma.JsonValue | null; extractedDriversJson: Prisma.JsonValue | null }, + policyId: string, + ): Promise { + const vehicles = asArray(doc.extractedVehiclesJson); + const drivers = asArray(doc.extractedDriversJson); + if (vehicles.length === 0 && drivers.length === 0) return; + + const policy = await this.prisma.policy.findUnique({ + where: { id: policyId }, + select: { customerId: true }, + }); + if (!policy) return; + + if (vehicles.length) { + const existing = await this.prisma.vehicle.findMany({ + where: { policyId }, + select: { vinNumber: true, licensePlate: true }, + }); + const seen = new Set( + existing.flatMap((v) => + [v.vinNumber, v.licensePlate].filter((k): k is string => !!k).map(norm), + ), + ); + for (const v of vehicles) { + const key = norm(v.vinNumber ?? v.licensePlate ?? ""); + if (!key || seen.has(key)) continue; + seen.add(key); + await this.prisma.vehicle.create({ + data: { + policyId, + customerId: policy.customerId, + make: v.make, + // ANA prints one BODY cell, not separate model/body columns, so + // it lands on `bodyType`; `model` stays null rather than being + // guessed out of the same string. + bodyType: v.bodyType, + modelYear: v.modelYear, + vinNumber: v.vinNumber, + licensePlate: v.licensePlate, + // "VEHICLE" / "TRAILER" / "TOWING" — the printed slot, which is + // the difference between the insured car and the trailer behind + // it and has no column of its own. + notes: v.item && v.item !== "VEHICLE" ? v.item : null, + }, + }); + } + } + + if (drivers.length) { + const existing = await this.prisma.insuredDriver.findMany({ + where: { policyId }, + select: { licenseNumber: true, fullName: true }, + }); + const seen = new Set( + existing.flatMap((d) => + [d.licenseNumber, d.fullName].filter((k): k is string => !!k).map(norm), + ), + ); + for (const d of drivers) { + const key = norm(d.licenseNumber ?? d.fullName ?? ""); + if (!key || seen.has(key)) continue; + seen.add(key); + await this.prisma.insuredDriver.create({ + data: { policyId, fullName: d.fullName, licenseNumber: d.licenseNumber }, + }); + } + } + } + /** * Stream the source PDF (`sourceKey`, set by `process` on the doc row) * into the policy's storage namespace and create a `PolicyDocument` * pointer. Trivial now that the doc row holds the exact source key — * the old per-page "which file did this page come from" walk is gone. */ - private async attachSourcePdf(sourceKey: string, policyId: string): Promise { + private async attachSourcePdf( + sourceKey: string, + policyId: string, + provider: string | null, + ): Promise { const got = await this.storage.getStream(sourceKey); const chunks: Buffer[] = []; for await (const c of got.stream) chunks.push(c as Buffer); @@ -556,7 +670,10 @@ export class PolicyOcrService { await this.prisma.policyDocument.create({ data: { policyId, - documentType: "GMX_POLICY", + // Named after whichever parser claimed the page. Was hardcoded + // `GMX_POLICY`, which mislabelled every ANA upload as a GMX + // document in the policy's file list. + documentType: `${provider ?? "OCR"}_POLICY`, storageKey: newKey, }, }); @@ -611,6 +728,7 @@ function buildPolicyUpdateFromDoc( extractedTotal: Prisma.Decimal | null; extractedCoveragesJson: Prisma.JsonValue | null; extractedPremiumPayment: string | null; + extractedCoveragePeriodDays: number | null; }, ): Prisma.PolicyUpdateInput { const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => { @@ -635,6 +753,12 @@ function buildPolicyUpdateFromDoc( policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom), policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo), policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate), + // Left undefined when the document didn't print a term, so the schema + // default (365) stands for GMX. ANA's by-the-day policies DO print one, + // and the default would otherwise turn a 4-day tourist policy into an + // annual one on the renewals screen. + coveragePeriodDays: + item.coveragePeriodDays ?? doc.extractedCoveragePeriodDays ?? undefined, currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined, netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium), policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee), @@ -683,6 +807,7 @@ function buildPolicyCreateFromDoc( extractedTotal: Prisma.Decimal | null; extractedCoveragesJson: Prisma.JsonValue | null; extractedPremiumPayment: string | null; + extractedCoveragePeriodDays: number | null; }, customerId: string, ): Prisma.PolicyUncheckedCreateInput { @@ -716,6 +841,12 @@ function buildPolicyCreateFromDoc( policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom), policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo), policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate), + // Left undefined when the document didn't print a term, so the schema + // default (365) stands for GMX. ANA's by-the-day policies DO print one, + // and the default would otherwise turn a 4-day tourist policy into an + // annual one on the renewals screen. + coveragePeriodDays: + item.coveragePeriodDays ?? doc.extractedCoveragePeriodDays ?? undefined, currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined, netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium), policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee), @@ -764,4 +895,18 @@ function strOrUndefDb(a: string | undefined, b: string | null): string | undefin if (a != null && a !== "") return a; if (b != null && b !== "") return b; return undefined; +} + +/** A JSON column the parser wrote as an array, read back as one. Anything + * else (null, DbNull, a legacy object shape) is an empty list rather than a + * crash — these columns are only ever populated by the parser, so a + * surprise shape means old data, not a caller to reject. */ +function asArray(value: Prisma.JsonValue | null): T[] { + return Array.isArray(value) ? (value as unknown as T[]) : []; +} + +/** Compare identifiers the way a person would: case- and space-insensitive. + * VINs and plates are printed inconsistently ("8BPX206" vs "8BPX 206"). */ +function norm(s: string): string { + return s.replace(/\s+/g, "").toUpperCase(); } \ No newline at end of file diff --git a/apps/web/src/app/polizas/[id]/page.tsx b/apps/web/src/app/polizas/[id]/page.tsx index 5c26b8f..419e8b6 100644 --- a/apps/web/src/app/polizas/[id]/page.tsx +++ b/apps/web/src/app/polizas/[id]/page.tsx @@ -648,10 +648,79 @@ function SiniestrosSection({ data }: { data: PolicyDetail }) { } /* -------------------------------------------------------- Coberturas */ -/** The legacy tables carry per-line coverage columns the target schema does - * not model; the migration preserved them verbatim in `coveragesJson`. */ +/** + * `coveragesJson` holds two unrelated shapes and the section renders each on + * its own terms: + * + * - **A Spanish-keyed object** — the legacy per-line coverage columns the + * target schema does not model, preserved verbatim by the migration. Every + * policy imported from Access carries this one. + * - **A `ParsedCoverage[]` array** — written by the policy OCR confirm step + * (GMX's coverage table, ANA's numbered risk sections). + * + * Running the object renderer over the array is what used to happen, and it + * produced a row per array index labelled "0", "1", "2" with `[object + * Object]` as its value — not a crash, so nothing surfaced it. + */ +interface StoredCoverage { + risk?: string; + insuredAmount?: number | null; + deductible?: string | null; + lossParticipation?: string | null; + premium?: number | null; +} + function CoberturasSection({ data }: { data: PolicyDetail }) { - const entries = Object.entries(data.coveragesJson ?? {}).filter( + const raw = data.coveragesJson ?? null; + + if (Array.isArray(raw)) { + const rows = (raw as StoredCoverage[]).filter((c) => c && c.risk); + if (rows.length === 0) return null; + return ( +
+ +
+
+ + + + + + + + + + + + {rows.map((c, i) => ( + + + + + + + + ))} + +
RiesgoSuma aseguradaPrimaDeducibleParticipación
{c.risk} + {c.insuredAmount == null + ? "—" + : formatMoney(c.insuredAmount.toString(), data.currency)} + + {c.premium == null + ? "—" + : formatMoney(c.premium.toString(), data.currency)} + {c.deductible ?? "—"}{c.lossParticipation ?? "—"}
+
+
+ Coberturas leídas del PDF de la aseguradora. +
+
+
+ ); + } + + const entries = Object.entries(raw ?? {}).filter( ([, v]) => v !== null && v !== "" && v !== 0, ); if (entries.length === 0) return null; diff --git a/apps/web/src/app/polizas/captura/page.tsx b/apps/web/src/app/polizas/captura/page.tsx index cb42775..6334cff 100644 --- a/apps/web/src/app/polizas/captura/page.tsx +++ b/apps/web/src/app/polizas/captura/page.tsx @@ -4,7 +4,7 @@ import { AppShell } from "@/components/AppShell"; import { PolicyCaptura } from "@/components/PolicyCaptura"; /** - * OCR mode of the policy intake screen. Drops the GMX PDF, walks through + * OCR mode of the policy intake screen. Drops the GMX or A.N.A. PDF, walks through * per-page review, confirms. Same wrapper as `/polizas/nuevo` (manual) * with `initialMode="auto"`, so the tab strip is identical and swapping * modes doesn't drop state. diff --git a/apps/web/src/components/PolicyCaptura.tsx b/apps/web/src/components/PolicyCaptura.tsx index a7b3190..34e84c6 100644 --- a/apps/web/src/components/PolicyCaptura.tsx +++ b/apps/web/src/components/PolicyCaptura.tsx @@ -12,7 +12,7 @@ import { useCan } from "@/lib/abilities"; * two ways in: * * - **manual** — `PolicyForm` keys every field by hand. - * - **auto** — `PolicyOcrIntake` uploads a GMX PDF, OCR proposes the + * - **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 @@ -28,7 +28,7 @@ export type PolicyCaptureMode = "manual" | "auto"; const MODE_HINT: Record = { 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 y el sistema propondrá los campos. Nada se registra sin tu confirmación.", + 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 }) { diff --git a/apps/web/src/components/PolicyOcrIntake.tsx b/apps/web/src/components/PolicyOcrIntake.tsx index 6c491c6..d77c930 100644 --- a/apps/web/src/components/PolicyOcrIntake.tsx +++ b/apps/web/src/components/PolicyOcrIntake.tsx @@ -13,9 +13,12 @@ import type { PolicyOcrBatch, PolicyOcrBatchStatus } from "@/lib/types"; /** * Insurance OCR intake — mirror of StatementIntake, scoped to the insurance - * side. Today the only provider is GMX; the parser dispatches on a brand - * wordmark (`Grupo Mexicano de Seguros` / `gmx.com.mx` / the GMX letterhead) - * and a new portal only needs a new BRAND entry plus a parser file. + * 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 @@ -102,8 +105,8 @@ export function PolicyOcrIntake() {
Cargando…
) : batches.length === 0 ? (
- Todavía no hay lotes de pólizas. Descargue el certificado del portal - de GMX y suéltelo arriba. + 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.
) : (
@@ -183,14 +186,14 @@ function UploadCard({ onDone }: { onDone: () => void }) { return (

- Subir PDFs de pólizas (GMX) + Subir PDFs de pólizas (GMX / A.N.A.)