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.)