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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,11 @@ function page(text: string): OcrPage {
|
|||||||
return { text, words: [], confidence: 0.95 };
|
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<typeof parsePolicy>): Record<string, ParsedCoverage> =>
|
||||||
|
Object.fromEntries(p.coverages.map((c) => [c.risk, c]));
|
||||||
|
|
||||||
describe("detectPolicyProvider", () => {
|
describe("detectPolicyProvider", () => {
|
||||||
it("claims GMX from the brand wordmark on the letterhead", () => {
|
it("claims GMX from the brand wordmark on the letterhead", () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -280,9 +285,6 @@ describe("parsePolicy / GMX especificación (PVL Hogar)", () => {
|
|||||||
"PVL Hogar - GMX Seguros Página: 10 de 10\n",
|
"PVL Hogar - GMX Seguros Página: 10 de 10\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
const byRisk = (p: ReturnType<typeof parsePolicy>) =>
|
|
||||||
Object.fromEntries(p.coverages.map((c) => [c.risk, c]));
|
|
||||||
|
|
||||||
it("reads a policy number whose groups are not the caratula's widths", () => {
|
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
|
// 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.
|
// reads one family and returns null on the other.
|
||||||
@@ -412,3 +414,467 @@ describe("parsePolicy / GMX especificación (PVL Hogar)", () => {
|
|||||||
expect(notes).toMatch(/W_HogarGMX_12\.11\.2025\.pdf/);
|
expect(notes).toMatch(/W_HogarGMX_12\.11\.2025\.pdf/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,13 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
@@ -37,6 +40,9 @@ export class ConfirmPolicyDocumentDto {
|
|||||||
@IsOptional() @IsNumber() brokerFee?: number;
|
@IsOptional() @IsNumber() brokerFee?: number;
|
||||||
@IsOptional() @IsNumber() total?: number;
|
@IsOptional() @IsNumber() total?: number;
|
||||||
@IsOptional() @IsString() premiumPayment?: string;
|
@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. */
|
/** Coverages parsed off the PDF, passed through verbatim to Policy.coveragesJson. */
|
||||||
@IsOptional() @IsObject() coveragesJson?: unknown;
|
@IsOptional() @IsObject() coveragesJson?: unknown;
|
||||||
|
|
||||||
@@ -70,6 +76,7 @@ export class ReviewPolicyDocumentDto {
|
|||||||
@IsOptional() @IsNumber() brokerFee?: number;
|
@IsOptional() @IsNumber() brokerFee?: number;
|
||||||
@IsOptional() @IsNumber() total?: number;
|
@IsOptional() @IsNumber() total?: number;
|
||||||
@IsOptional() @IsString() premiumPayment?: string;
|
@IsOptional() @IsString() premiumPayment?: string;
|
||||||
|
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
|
||||||
@IsOptional() @IsObject() coveragesJson?: unknown;
|
@IsOptional() @IsObject() coveragesJson?: unknown;
|
||||||
|
|
||||||
/** Set by the reviewer when the document matched an existing Policy. */
|
/** Set by the reviewer when the document matched an existing Policy. */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { PrismaService } from "../prisma/prisma.service";
|
|||||||
import { StorageService } from "../storage/storage.service";
|
import { StorageService } from "../storage/storage.service";
|
||||||
import type { UploadedFileLike } from "../storage/upload-file";
|
import type { UploadedFileLike } from "../storage/upload-file";
|
||||||
import { OCR_PROVIDER, type OcrPage, type OcrProvider } from "../statements/ocr/ocr.provider";
|
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 { PolicyMatcherService } from "./policy-matcher.service";
|
||||||
import type {
|
import type {
|
||||||
ConfirmPolicyBatchDto,
|
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({
|
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 }));
|
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
||||||
@@ -112,6 +115,7 @@ export class PolicyOcrService {
|
|||||||
|
|
||||||
let fileOrdinal = 0;
|
let fileOrdinal = 0;
|
||||||
let globalPageOrdinal = 0;
|
let globalPageOrdinal = 0;
|
||||||
|
const providersSeen = new Set<string>();
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
fileOrdinal += 1;
|
fileOrdinal += 1;
|
||||||
const sourceKey = `policy-ocr/${batchId}/source-${fileOrdinal}.pdf`;
|
const sourceKey = `policy-ocr/${batchId}/source-${fileOrdinal}.pdf`;
|
||||||
@@ -155,6 +159,7 @@ export class PolicyOcrService {
|
|||||||
if (parsed.provider === "") {
|
if (parsed.provider === "") {
|
||||||
throw new Error("no se reconoció el proveedor");
|
throw new Error("no se reconoció el proveedor");
|
||||||
}
|
}
|
||||||
|
providersSeen.add(parsed.provider);
|
||||||
const match = await this.matcher.match(parsed);
|
const match = await this.matcher.match(parsed);
|
||||||
const notes = [...parsed.notes, match.note].filter(Boolean);
|
const notes = [...parsed.notes, match.note].filter(Boolean);
|
||||||
// Confident when exactly one Policy carries the printed number —
|
// Confident when exactly one Policy carries the printed number —
|
||||||
@@ -193,12 +198,19 @@ export class PolicyOcrService {
|
|||||||
? (parsed.coverages as unknown as Prisma.InputJsonValue)
|
? (parsed.coverages as unknown as Prisma.InputJsonValue)
|
||||||
: Prisma.DbNull,
|
: Prisma.DbNull,
|
||||||
extractedPremiumPayment: parsed.premiumPayment,
|
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,
|
matchedPolicyId: match.policyId,
|
||||||
matchedCustomerId: match.customerId,
|
matchedCustomerId: match.customerId,
|
||||||
matchCandidates: match.candidates.length
|
matchCandidates: match.candidates.length
|
||||||
? (match.candidates as unknown as Prisma.InputJsonValue)
|
? (match.candidates as unknown as Prisma.InputJsonValue)
|
||||||
: Prisma.DbNull,
|
: Prisma.DbNull,
|
||||||
matchNote: notes.join("; ").slice(0, 190),
|
matchNote: notes.join("; "),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -211,7 +223,7 @@ export class PolicyOcrService {
|
|||||||
pageNumber: fileOrdinal,
|
pageNumber: fileOrdinal,
|
||||||
storageKey: sourceKey,
|
storageKey: sourceKey,
|
||||||
status: "OCR_FAILED",
|
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({
|
await this.prisma.policyOcrBatch.update({
|
||||||
where: { id: batchId },
|
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)
|
? (dto.coveragesJson as Prisma.InputJsonValue)
|
||||||
: undefined,
|
: undefined,
|
||||||
extractedPremiumPayment: dto.premiumPayment ?? undefined,
|
extractedPremiumPayment: dto.premiumPayment ?? undefined,
|
||||||
|
extractedCoveragePeriodDays: dto.coveragePeriodDays ?? undefined,
|
||||||
matchedPolicyId,
|
matchedPolicyId,
|
||||||
matchedCustomerId,
|
matchedCustomerId,
|
||||||
status: dto.forceConfirm ? "CONFIRMED" : "MATCHED",
|
status: dto.forceConfirm ? "CONFIRMED" : "MATCHED",
|
||||||
@@ -474,14 +493,18 @@ export class PolicyOcrService {
|
|||||||
policyId = created.id;
|
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`)
|
// 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 —
|
// 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
|
// the previous per-page "which file did this page come from" walk is
|
||||||
// gone because one PDF = one doc now.
|
// 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
|
// explicitly asked (`postPremium` true) and netPremium parses — without
|
||||||
// that gate a missing premium would silently book $0.
|
// that gate a missing premium would silently book $0.
|
||||||
let postedTransactionId: string | null = null;
|
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<void> {
|
||||||
|
const vehicles = asArray<ParsedVehicle>(doc.extractedVehiclesJson);
|
||||||
|
const drivers = asArray<ParsedDriver>(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)
|
* Stream the source PDF (`sourceKey`, set by `process` on the doc row)
|
||||||
* into the policy's storage namespace and create a `PolicyDocument`
|
* into the policy's storage namespace and create a `PolicyDocument`
|
||||||
* pointer. Trivial now that the doc row holds the exact source key —
|
* 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.
|
* the old per-page "which file did this page come from" walk is gone.
|
||||||
*/
|
*/
|
||||||
private async attachSourcePdf(sourceKey: string, policyId: string): Promise<void> {
|
private async attachSourcePdf(
|
||||||
|
sourceKey: string,
|
||||||
|
policyId: string,
|
||||||
|
provider: string | null,
|
||||||
|
): Promise<void> {
|
||||||
const got = await this.storage.getStream(sourceKey);
|
const got = await this.storage.getStream(sourceKey);
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const c of got.stream) chunks.push(c as 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({
|
await this.prisma.policyDocument.create({
|
||||||
data: {
|
data: {
|
||||||
policyId,
|
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,
|
storageKey: newKey,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -611,6 +728,7 @@ function buildPolicyUpdateFromDoc(
|
|||||||
extractedTotal: Prisma.Decimal | null;
|
extractedTotal: Prisma.Decimal | null;
|
||||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||||
extractedPremiumPayment: string | null;
|
extractedPremiumPayment: string | null;
|
||||||
|
extractedCoveragePeriodDays: number | null;
|
||||||
},
|
},
|
||||||
): Prisma.PolicyUpdateInput {
|
): Prisma.PolicyUpdateInput {
|
||||||
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
|
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
|
||||||
@@ -635,6 +753,12 @@ function buildPolicyUpdateFromDoc(
|
|||||||
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
||||||
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
||||||
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
|
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,
|
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
|
||||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||||
@@ -683,6 +807,7 @@ function buildPolicyCreateFromDoc(
|
|||||||
extractedTotal: Prisma.Decimal | null;
|
extractedTotal: Prisma.Decimal | null;
|
||||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||||
extractedPremiumPayment: string | null;
|
extractedPremiumPayment: string | null;
|
||||||
|
extractedCoveragePeriodDays: number | null;
|
||||||
},
|
},
|
||||||
customerId: string,
|
customerId: string,
|
||||||
): Prisma.PolicyUncheckedCreateInput {
|
): Prisma.PolicyUncheckedCreateInput {
|
||||||
@@ -716,6 +841,12 @@ function buildPolicyCreateFromDoc(
|
|||||||
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
||||||
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
||||||
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
|
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,
|
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
|
||||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||||
@@ -765,3 +896,17 @@ function strOrUndefDb(a: string | undefined, b: string | null): string | undefin
|
|||||||
if (b != null && b !== "") return b;
|
if (b != null && b !== "") return b;
|
||||||
return undefined;
|
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<T>(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();
|
||||||
|
}
|
||||||
@@ -648,10 +648,79 @@ function SiniestrosSection({ data }: { data: PolicyDetail }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------- Coberturas */
|
/* -------------------------------------------------------- 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 }) {
|
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 (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="seguros" title="Coberturas" count={rows.length} />
|
||||||
|
<div className="card">
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Riesgo</th>
|
||||||
|
<th className="num">Suma asegurada</th>
|
||||||
|
<th className="num">Prima</th>
|
||||||
|
<th>Deducible</th>
|
||||||
|
<th>Participación</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((c, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{c.risk}</td>
|
||||||
|
<td className="num">
|
||||||
|
{c.insuredAmount == null
|
||||||
|
? "—"
|
||||||
|
: formatMoney(c.insuredAmount.toString(), data.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{c.premium == null
|
||||||
|
? "—"
|
||||||
|
: formatMoney(c.premium.toString(), data.currency)}
|
||||||
|
</td>
|
||||||
|
<td>{c.deductible ?? "—"}</td>
|
||||||
|
<td>{c.lossParticipation ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||||
|
Coberturas leídas del PDF de la aseguradora.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(raw ?? {}).filter(
|
||||||
([, v]) => v !== null && v !== "" && v !== 0,
|
([, v]) => v !== null && v !== "" && v !== 0,
|
||||||
);
|
);
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AppShell } from "@/components/AppShell";
|
|||||||
import { PolicyCaptura } from "@/components/PolicyCaptura";
|
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)
|
* per-page review, confirms. Same wrapper as `/polizas/nuevo` (manual)
|
||||||
* with `initialMode="auto"`, so the tab strip is identical and swapping
|
* with `initialMode="auto"`, so the tab strip is identical and swapping
|
||||||
* modes doesn't drop state.
|
* modes doesn't drop state.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useCan } from "@/lib/abilities";
|
|||||||
* two ways in:
|
* two ways in:
|
||||||
*
|
*
|
||||||
* - **manual** — `PolicyForm` keys every field by hand.
|
* - **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.
|
* policy, a human still confirms.
|
||||||
*
|
*
|
||||||
* Both end at the same place (a `Policy` row on a customer's file) so they
|
* 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<PolicyCaptureMode, string> = {
|
const MODE_HINT: Record<PolicyCaptureMode, string> = {
|
||||||
manual:
|
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.",
|
"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 }) {
|
export function PolicyCaptura({ initialMode = "manual" }: { initialMode?: PolicyCaptureMode }) {
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import type { PolicyOcrBatch, PolicyOcrBatchStatus } from "@/lib/types";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Insurance OCR intake — mirror of StatementIntake, scoped to the insurance
|
* Insurance OCR intake — mirror of StatementIntake, scoped to the insurance
|
||||||
* side. Today the only provider is GMX; the parser dispatches on a brand
|
* side. GMX and A.N.A. today; the parser dispatches on a brand wordmark
|
||||||
* wordmark (`Grupo Mexicano de Seguros` / `gmx.com.mx` / the GMX letterhead)
|
* (`Grupo Mexicano de Seguros` / `gmx.com.mx`, `A.N.A. Compañía de Seguros` /
|
||||||
* and a new portal only needs a new BRAND entry plus a parser file.
|
* `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
|
* 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
|
* is one mode of one job (staff uploading whatever PDFs the office has on
|
||||||
@@ -102,8 +105,8 @@ export function PolicyOcrIntake() {
|
|||||||
<div className="state-box">Cargando…</div>
|
<div className="state-box">Cargando…</div>
|
||||||
) : batches.length === 0 ? (
|
) : batches.length === 0 ? (
|
||||||
<div className="state-box">
|
<div className="state-box">
|
||||||
Todavía no hay lotes de pólizas. Descargue el certificado del portal
|
Todavía no hay lotes de pólizas. Descargue la póliza del portal de
|
||||||
de GMX y suéltelo arriba.
|
GMX o de A.N.A. y suéltela arriba.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="tx-scroll">
|
<div className="tx-scroll">
|
||||||
@@ -183,14 +186,14 @@ function UploadCard({ onDone }: { onDone: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<section className="card" style={{ padding: 16 }}>
|
<section className="card" style={{ padding: 16 }}>
|
||||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||||
Subir PDFs de pólizas (GMX)
|
Subir PDFs de pólizas (GMX / A.N.A.)
|
||||||
</h2>
|
</h2>
|
||||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||||
<label>
|
<label>
|
||||||
<span className="page-sub">Referencia (opcional)</span>
|
<span className="page-sub">Referencia (opcional)</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder="ej. GMX julio 2026"
|
placeholder="ej. ANA agosto 2026"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
netPremium: doc.extractedNetPremium ?? "",
|
netPremium: doc.extractedNetPremium ?? "",
|
||||||
total: doc.extractedTotal ?? "",
|
total: doc.extractedTotal ?? "",
|
||||||
premiumPayment: doc.extractedPremiumPayment ?? "",
|
premiumPayment: doc.extractedPremiumPayment ?? "",
|
||||||
|
coveragePeriodDays: doc.extractedCoveragePeriodDays?.toString() ?? "",
|
||||||
postPremium: doc.extractedNetPremium != null && Number(doc.extractedNetPremium) > 0,
|
postPremium: doc.extractedNetPremium != null && Number(doc.extractedNetPremium) > 0,
|
||||||
});
|
});
|
||||||
const [customerId, setCustomerId] = useState(
|
const [customerId, setCustomerId] = useState(
|
||||||
@@ -311,6 +312,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
netPremium: numOrUndef(v.netPremium),
|
netPremium: numOrUndef(v.netPremium),
|
||||||
total: numOrUndef(v.total),
|
total: numOrUndef(v.total),
|
||||||
premiumPayment: trimOrUndef(v.premiumPayment),
|
premiumPayment: trimOrUndef(v.premiumPayment),
|
||||||
|
coveragePeriodDays: numOrUndef(v.coveragePeriodDays),
|
||||||
matchedPolicyId: policyId || undefined,
|
matchedPolicyId: policyId || undefined,
|
||||||
matchedCustomerId: !policyId && customerId ? customerId : undefined,
|
matchedCustomerId: !policyId && customerId ? customerId : undefined,
|
||||||
forceConfirm: true,
|
forceConfirm: true,
|
||||||
@@ -332,6 +334,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
netPremium: reviewInput.netPremium,
|
netPremium: reviewInput.netPremium,
|
||||||
total: reviewInput.total,
|
total: reviewInput.total,
|
||||||
premiumPayment: reviewInput.premiumPayment,
|
premiumPayment: reviewInput.premiumPayment,
|
||||||
|
coveragePeriodDays: reviewInput.coveragePeriodDays,
|
||||||
coveragesJson: (doc.extractedCoveragesJson ?? undefined) as
|
coveragesJson: (doc.extractedCoveragesJson ?? undefined) as
|
||||||
| PolicyOcrCoverage[]
|
| PolicyOcrCoverage[]
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -454,6 +457,17 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
onChange={(e) => set("policyDate", e.target.value)}
|
onChange={(e) => set("policyDate", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
{/* ANA sells 3- and 4-day tourist policies; left blank the
|
||||||
|
póliza keeps the 365-day default. */}
|
||||||
|
<Field label="Días de vigencia">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={v.coveragePeriodDays}
|
||||||
|
onChange={(e) => set("coveragePeriodDays", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field label="Moneda">
|
<Field label="Moneda">
|
||||||
<select
|
<select
|
||||||
className="input select"
|
className="input select"
|
||||||
@@ -523,6 +537,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Riesgo</th>
|
<th>Riesgo</th>
|
||||||
<th className="num">Suma</th>
|
<th className="num">Suma</th>
|
||||||
|
<th className="num">Prima</th>
|
||||||
<th>Deducible</th>
|
<th>Deducible</th>
|
||||||
<th>Participación</th>
|
<th>Participación</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -534,6 +549,14 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
<td className="num">
|
<td className="num">
|
||||||
{formatMoney(c.insuredAmount?.toString() ?? null, v.currency)}
|
{formatMoney(c.insuredAmount?.toString() ?? null, v.currency)}
|
||||||
</td>
|
</td>
|
||||||
|
{/* ANA's add-on sections print what the coverage COST
|
||||||
|
where the others print what it pays. Kept in its own
|
||||||
|
column so the two are never added together. */}
|
||||||
|
<td className="num">
|
||||||
|
{c.premium == null
|
||||||
|
? "—"
|
||||||
|
: formatMoney(c.premium.toString(), v.currency)}
|
||||||
|
</td>
|
||||||
<td>{c.deductible ?? "—"}</td>
|
<td>{c.deductible ?? "—"}</td>
|
||||||
<td>{c.lossParticipation ?? "—"}</td>
|
<td>{c.lossParticipation ?? "—"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -543,6 +566,66 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Vehicles and named drivers are read-only here: they are written
|
||||||
|
* as their own Vehicle / InsuredDriver rows on confirm, and the
|
||||||
|
* policy screen is where they get edited. Showing them is what
|
||||||
|
* lets a reviewer catch a misread VIN before it is applied.
|
||||||
|
*/}
|
||||||
|
{doc.extractedVehiclesJson && doc.extractedVehiclesJson.length > 0 && (
|
||||||
|
<details>
|
||||||
|
<summary>Unidades ({doc.extractedVehiclesJson.length})</summary>
|
||||||
|
<table className="tx-table" style={{ marginTop: 8 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Año</th>
|
||||||
|
<th>Marca</th>
|
||||||
|
<th>Carrocería</th>
|
||||||
|
<th>Serie</th>
|
||||||
|
<th>Placas</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{doc.extractedVehiclesJson.map((veh, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{veh.item}</td>
|
||||||
|
<td>{veh.modelYear ?? "—"}</td>
|
||||||
|
<td>{veh.make ?? "—"}</td>
|
||||||
|
<td>{veh.bodyType ?? "—"}</td>
|
||||||
|
<td>{veh.vinNumber ?? "—"}</td>
|
||||||
|
<td>{veh.licensePlate ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{doc.extractedDriversJson && doc.extractedDriversJson.length > 0 && (
|
||||||
|
<details>
|
||||||
|
<summary>Conductores ({doc.extractedDriversJson.length})</summary>
|
||||||
|
<table className="tx-table" style={{ marginTop: 8 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Licencia</th>
|
||||||
|
<th>Teléfono</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{doc.extractedDriversJson.map((d, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{d.fullName}</td>
|
||||||
|
<td>{d.licenseNumber ?? "—"}</td>
|
||||||
|
<td>{d.phone ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
{candidates.length > 1 && (
|
{candidates.length > 1 && (
|
||||||
<Field label="Póliza destino">
|
<Field label="Póliza destino">
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -1432,7 +1432,7 @@ export function statementPageUrl(documentId: string): string {
|
|||||||
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------- Policy OCR (GMX) */
|
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
|
||||||
|
|
||||||
export function getPolicyOcrStatus(): Promise<{
|
export function getPolicyOcrStatus(): Promise<{
|
||||||
ocrAvailable: boolean;
|
ocrAvailable: boolean;
|
||||||
|
|||||||
@@ -1405,7 +1405,7 @@ export interface DiscardBatchResult {
|
|||||||
rejected: number;
|
rejected: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------ Policy OCR intake (GMX) */
|
/* ------------------------------------- Policy OCR intake (GMX / ANA) */
|
||||||
|
|
||||||
export type PolicyOcrBatchStatus =
|
export type PolicyOcrBatchStatus =
|
||||||
| "UPLOADED"
|
| "UPLOADED"
|
||||||
@@ -1447,6 +1447,27 @@ export interface PolicyOcrCoverage {
|
|||||||
insuredAmount: number | null;
|
insuredAmount: number | null;
|
||||||
deductible: string | null;
|
deductible: string | null;
|
||||||
lossParticipation: string | null;
|
lossParticipation: string | null;
|
||||||
|
/** What the coverage COST, where the layout prints it separately from what
|
||||||
|
* it pays out (ANA's add-on sections). GMX never prints one. */
|
||||||
|
premium?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of ANA's `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table. */
|
||||||
|
export interface PolicyOcrVehicle {
|
||||||
|
item: string;
|
||||||
|
modelYear: string | null;
|
||||||
|
make: string | null;
|
||||||
|
bodyType: string | null;
|
||||||
|
vinNumber: string | null;
|
||||||
|
licensePlate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyOcrDriver {
|
||||||
|
fullName: string;
|
||||||
|
licenseNumber: string | null;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyOcrMatchCandidate {
|
export interface PolicyOcrMatchCandidate {
|
||||||
@@ -1478,6 +1499,9 @@ export interface PolicyOcrDocument {
|
|||||||
extractedTotal: string | null;
|
extractedTotal: string | null;
|
||||||
extractedCoveragesJson: PolicyOcrCoverage[] | null;
|
extractedCoveragesJson: PolicyOcrCoverage[] | null;
|
||||||
extractedPremiumPayment: string | null;
|
extractedPremiumPayment: string | null;
|
||||||
|
extractedCoveragePeriodDays: number | null;
|
||||||
|
extractedVehiclesJson: PolicyOcrVehicle[] | null;
|
||||||
|
extractedDriversJson: PolicyOcrDriver[] | null;
|
||||||
matchedPolicy: {
|
matchedPolicy: {
|
||||||
id: string;
|
id: string;
|
||||||
policyNumber: string | null;
|
policyNumber: string | null;
|
||||||
@@ -1505,6 +1529,7 @@ export interface PolicyOcrReviewInput {
|
|||||||
brokerFee?: number;
|
brokerFee?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
premiumPayment?: string;
|
premiumPayment?: string;
|
||||||
|
coveragePeriodDays?: number;
|
||||||
coveragesJson?: PolicyOcrCoverage[];
|
coveragesJson?: PolicyOcrCoverage[];
|
||||||
matchedPolicyId?: string;
|
matchedPolicyId?: string;
|
||||||
matchedCustomerId?: string;
|
matchedCustomerId?: string;
|
||||||
@@ -1530,6 +1555,7 @@ export interface PolicyOcrConfirmDocument {
|
|||||||
brokerFee?: number;
|
brokerFee?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
premiumPayment?: string;
|
premiumPayment?: string;
|
||||||
|
coveragePeriodDays?: number;
|
||||||
coveragesJson?: PolicyOcrCoverage[];
|
coveragesJson?: PolicyOcrCoverage[];
|
||||||
postPremium?: boolean;
|
postPremium?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-4
@@ -165,12 +165,17 @@ Each of these is a known, deliberate stopping point rather than a bug.
|
|||||||
- No `SKIPPED_NO_EMAIL` worklist (see 1.9).
|
- No `SKIPPED_NO_EMAIL` worklist (see 1.9).
|
||||||
|
|
||||||
**Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md)
|
**Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md)
|
||||||
- **GMX only.** The dispatcher is a `[provider, pattern]` table plus a parser
|
- **GMX and A.N.A. only.** The dispatcher is a `[provider, pattern]` table plus
|
||||||
map, so a second carrier is one function and two entries — but no other
|
a parser map, so a third carrier is one function and two entries — but no
|
||||||
layout has been seen, and guessing produces a parser nobody can verify.
|
other layout has been seen, and guessing produces a parser nobody can verify.
|
||||||
- **The `recibo` PDF is unread.** The GMX certificate carries no premium at
|
- **The `recibo` PDF is unread.** The GMX certificate carries no premium at
|
||||||
all; reading the separate receipt and pairing it to its certificate is what
|
all; reading the separate receipt and pairing it to its certificate is what
|
||||||
would let `postPremium` stop being a manual tick.
|
would let `postPremium` stop being a manual tick. A.N.A. prints its premium
|
||||||
|
on the face, so this is a GMX-only gap.
|
||||||
|
- **No `policyTypeId` from OCR.** A.N.A.'s two faces are distinguishable in the
|
||||||
|
parser (automobile vs driver's policy) and the platform has a `PolicyType`
|
||||||
|
discriminator, but confirm never sets one — partly because the `policy_types`
|
||||||
|
rows are themselves incomplete (INSURANCE §live defects).
|
||||||
- **No versioning.** A re-issued policy arrives as a new certificate with the
|
- **No versioning.** A re-issued policy arrives as a new certificate with the
|
||||||
same number and confirm updates the existing row. Nothing records that this
|
same number and confirm updates the existing row. Nothing records that this
|
||||||
is the 2027 issue of that policy.
|
is the 2027 issue of that policy.
|
||||||
|
|||||||
+129
-12
@@ -34,7 +34,7 @@ was **reused, not copied**.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| API module | `apps/api/src/policy-ocr/` (service, controller, DTOs, matcher, parser) |
|
| API module | `apps/api/src/policy-ocr/` (service, controller, DTOs, matcher, parser) |
|
||||||
| Shared OCR seam | `apps/api/src/ocr/ocr.module.ts` |
|
| Shared OCR seam | `apps/api/src/ocr/ocr.module.ts` |
|
||||||
| Tables | `policy_ocr_batches`, `policy_ocr_documents` (`20260801000000_policy_ocr_intake`) |
|
| Tables | `policy_ocr_batches`, `policy_ocr_documents` (`20260801000000_policy_ocr_intake`, extended by `20260815120000_policy_ocr_ana`) |
|
||||||
| Web | `components/PolicyCaptura.tsx` (tab shell), `PolicyOcrIntake.tsx` (upload), `PolicyOcrReview.tsx` (review queue) |
|
| Web | `components/PolicyCaptura.tsx` (tab shell), `PolicyOcrIntake.tsx` (upload), `PolicyOcrReview.tsx` (review queue) |
|
||||||
| Abilities | `policy:ingest`, `policy:ocr-review` — both **STAFF** |
|
| Abilities | `policy:ingest`, `policy:ocr-review` — both **STAFF** |
|
||||||
|
|
||||||
@@ -194,6 +194,94 @@ amount, never as `0`: a coverage insured for zero and an excluded coverage are
|
|||||||
the same number and very different facts, and `ParsedCoverage` has no field
|
the same number and very different facts, and `ParsedCoverage` has no field
|
||||||
for the distinction.
|
for the distinction.
|
||||||
|
|
||||||
|
## A.N.A. ships two unrelated faces too
|
||||||
|
|
||||||
|
`A.N.A. Compañía de Seguros` is the Rosarito office's tourist auto book. Same
|
||||||
|
split as GMX, different reason: GMX ships two *documents about one policy*,
|
||||||
|
A.N.A. ships two *products*.
|
||||||
|
|
||||||
|
| | **AUTOMOBILE** (`SPECIAL POLICY FOR TOURISTS`) | **DRIVER´S POLICY** (the office says *licencia*) |
|
||||||
|
|---|---|---|
|
||||||
|
| Insures | a specific car | up to five named drivers, whatever they drive |
|
||||||
|
| Vehicle table | `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` | **none** |
|
||||||
|
| Insured | one `INSURED` cell | numbered `POLICY HOLDER` list |
|
||||||
|
| Value columns | one (`LIMIT OF LIABILITY`) | two (`SUM INSURED`, `PREMIUM`) |
|
||||||
|
| Sections | 9, numbered | 6, unnumbered, **in a different order** |
|
||||||
|
| Parser | `parseAnaAutomobile` | `parseAnaDriverPolicy` |
|
||||||
|
|
||||||
|
Selected by `isAnaDriverPolicy` on the title band.
|
||||||
|
|
||||||
|
The **four automobile products** the office sells — amplia and responsabilidad
|
||||||
|
civil, each annual or by-the-day — are the **same layout with different
|
||||||
|
numbers**. "Amplia" prints a 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 parser rather than four.
|
||||||
|
|
||||||
|
Things worth knowing before touching the ANA parsers:
|
||||||
|
|
||||||
|
- **These are born-digital portal PDFs**, so `pdftotext -layout` returns exact
|
||||||
|
glyphs and exact columns. The driver's policy parser uses that: `SUM
|
||||||
|
INSURED` and `PREMIUM` print the same shape (`100,000.00 usd.` /
|
||||||
|
`18.70 usd.`) with no per-row label, so **horizontal position is the only
|
||||||
|
thing that separates them**. The split is computed from the header's own
|
||||||
|
column offsets rather than hardcoded, because they shift between products.
|
||||||
|
If a scan ever arrives without column fidelity, every amount is reported as
|
||||||
|
a sum insured and the reviewer is told the split failed.
|
||||||
|
- **The money row is read positionally, not by finding six amounts.** An
|
||||||
|
unused `DISCOUNT` prints as a bare `-`, so an "amounts in order" reading
|
||||||
|
shifts every value one column left on a discounted policy. The parser
|
||||||
|
requires exactly six whitespace-separated cells or reports the row unread.
|
||||||
|
- **Each PDF prints its face two or three times** (ORIGINAL, AGENT COPY, then
|
||||||
|
a summary receipt and three travel ID 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, not as a bug, so nothing downstream would catch it.
|
||||||
|
- **Two five-digit numbers sit in the header band** and only one is the agent
|
||||||
|
clave: the other is the agent's own postal code
|
||||||
|
(`ROSARITO, BAJA CALIFORNIA 22710`). Likewise the agent's street address
|
||||||
|
reads `BENITO JUAREZ 25 No.50 INT 38`, three lines above the `No.` cell that
|
||||||
|
holds the policy number — hence the two-space floor after `No.`.
|
||||||
|
- **Sections 6–8 print a PREMIUM where the others print a limit.** $40 is what
|
||||||
|
legal aid *cost*, not a $40 liability limit, so it lands on
|
||||||
|
`ParsedCoverage.premium` (a field GMX never fills) and gets its own column
|
||||||
|
on the review screen. Adding the two together would be meaningless.
|
||||||
|
- **`coveragePeriodDays` matters here and nowhere else.** 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. The term
|
||||||
|
is derived from the two dates and cross-checked against the printed `DAYS`
|
||||||
|
cell; a disagreement is noted rather than resolved.
|
||||||
|
|
||||||
|
Exclusions follow the GMX rule — recorded in the risk label
|
||||||
|
(`MATERIAL DAMAGE — VEHICLE: EXCLUDED`) with a null amount, never as `0`. It
|
||||||
|
matters more here: a responsabilidad-civil policy prints `0.00` for material
|
||||||
|
damage, so the two are visually identical on the page.
|
||||||
|
|
||||||
|
### Vehicles and drivers
|
||||||
|
|
||||||
|
A.N.A. is the first provider whose face carries either, so confirm now writes
|
||||||
|
`Vehicle` and `InsuredDriver` rows alongside the `Policy`
|
||||||
|
(`applyVehiclesAndDrivers`). The parsed values are stored on the document as
|
||||||
|
`extractedVehiclesJson` / `extractedDriversJson` and shown read-only in the
|
||||||
|
review queue, so a misread VIN is catchable before it is applied.
|
||||||
|
|
||||||
|
Both inserts skip a row that already exists on the policy, matched on the
|
||||||
|
identifier the document prints — VIN then plate for a vehicle (A.N.A.'s
|
||||||
|
TRAILER and TOWING slots have no VIN), licence number then name for a driver.
|
||||||
|
The case that forces this is confirming a **renewal** onto an existing policy:
|
||||||
|
a blind insert leaves the customer with the same VIN listed twice and no way
|
||||||
|
to tell which row the renewal belongs to.
|
||||||
|
|
||||||
|
Nothing is ever updated or deleted there. A vehicle whose plate changed lands
|
||||||
|
as a second row for a human to reconcile — the safe half of the mistake, since
|
||||||
|
an overwrite would destroy the only record of what was insured last term.
|
||||||
|
|
||||||
|
The vehicle table is parsed **by token role, not by column offset**, because
|
||||||
|
`BODY` is the cell 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 17-character VIN is the anchor and `BODY` is whatever sits between the
|
||||||
|
make and it.
|
||||||
|
|
||||||
## What the parser reads, and the field it cannot
|
## What the parser reads, and the field it cannot
|
||||||
|
|
||||||
`ParsedPolicy` fields are all nullable on purpose: each carrier prints a
|
`ParsedPolicy` fields are all nullable on purpose: each carrier prints a
|
||||||
@@ -206,6 +294,12 @@ insured, broker (→ `Policy.agentName`), legal address, ZIP, `policyFrom` /
|
|||||||
per-coverage table (risk, insured amount, deductible, loss participation)
|
per-coverage table (risk, insured amount, deductible, loss participation)
|
||||||
preserved verbatim.
|
preserved verbatim.
|
||||||
|
|
||||||
|
Read from an A.N.A. face: all of the above except additional insured and
|
||||||
|
broker parens, **plus** the premium (A.N.A. prints it — see below), the policy
|
||||||
|
fee, the total, the term in days, the vehicle table, and the named drivers
|
||||||
|
with their US licence numbers. The tax and the agent clave have no column in
|
||||||
|
the schema and ride in the notes.
|
||||||
|
|
||||||
> **The GMX certificate carries no premium.** Not "sometimes missing" — the
|
> **The GMX certificate carries no premium.** Not "sometimes missing" — the
|
||||||
> document does not have the figure. It lives on GMX's **separate `recibo`
|
> document does not have the figure. It lives on GMX's **separate `recibo`
|
||||||
> PDF**. The parser leaves `netPremium` / `policyFee` / `brokerFee` / `total`
|
> PDF**. The parser leaves `netPremium` / `policyFee` / `brokerFee` / `total`
|
||||||
@@ -218,6 +312,10 @@ This is also why confirm never overwrites an existing `Policy.netPremium`
|
|||||||
with null: the certificate not carrying a premium is not evidence that the
|
with null: the certificate not carrying a premium is not evidence that the
|
||||||
premium is gone.
|
premium is gone.
|
||||||
|
|
||||||
|
A.N.A.'s faces do print one — the `DISCOUNT / PREMIUM / POLICY FEE / TAX /
|
||||||
|
LOCAL TAX / TOTAL` row is on the same page — so an ANA document reaches the
|
||||||
|
review queue with `netPremium` populated and `postPremium` already ticked.
|
||||||
|
|
||||||
Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
|
Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
|
||||||
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
|
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
|
||||||
and free text, and normalising them would lose the distinction.
|
and free text, and normalising them would lose the distinction.
|
||||||
@@ -229,9 +327,14 @@ Per confirmed document, in order:
|
|||||||
1. **The `Policy` row** — updated if a policy was matched, created under the
|
1. **The `Policy` row** — updated if a policy was matched, created under the
|
||||||
picked customer if not. Only non-null `extracted*` fields are written; null
|
picked customer if not. Only non-null `extracted*` fields are written; null
|
||||||
never overwrites existing data.
|
never overwrites existing data.
|
||||||
2. **A `PolicyDocument`** — the source PDF is streamed into the policy's
|
2. **`Vehicle` and `InsuredDriver` rows** — for the providers whose face
|
||||||
storage namespace and attached, so the paperwork stays with the policy.
|
carries them (A.N.A.; never GMX Hogar), skipping any that already exist on
|
||||||
3. **Optionally a `Transaction`** — `INSURANCE` domain, negative amount
|
the policy. See *Vehicles and drivers* above.
|
||||||
|
3. **A `PolicyDocument`** — the source PDF is streamed into the policy's
|
||||||
|
storage namespace and attached, so the paperwork stays with the policy. Its
|
||||||
|
`documentType` is named after whichever parser claimed the page
|
||||||
|
(`ANA_POLICY`, `GMX_POLICY`).
|
||||||
|
4. **Optionally a `Transaction`** — `INSURANCE` domain, negative amount
|
||||||
(a charge), `captureSource: "OCR"`, `captureRef` = the document id.
|
(a charge), `captureSource: "OCR"`, `captureRef` = the document id.
|
||||||
|
|
||||||
The ledger write is **opt-in twice over**: staff must tick `postPremium`
|
The ledger write is **opt-in twice over**: staff must tick `postPremium`
|
||||||
@@ -271,10 +374,11 @@ feature is disabled.
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`apps/api/src/policy-ocr/parsers/policy-parser.spec.ts` — 24 cases against
|
`apps/api/src/policy-ocr/parsers/policy-parser.spec.ts` — 53 cases against
|
||||||
verbatim text extracted from two real documents, indentation and blank lines
|
verbatim text extracted from five real documents, indentation and blank lines
|
||||||
included (the column positions are what the parser reads, so a cleaned-up
|
included (the column positions are what the parser reads, so a cleaned-up
|
||||||
fixture would test nothing).
|
fixture would test nothing — and on A.N.A.'s driver's policy the offsets are
|
||||||
|
literally the only thing separating two columns).
|
||||||
|
|
||||||
From `HC_Folio_000767_Traduccion.pdf` (caratula): provider detection from the
|
From `HC_Folio_000767_Traduccion.pdf` (caratula): provider detection from the
|
||||||
wordmark and from the footer URL, the header fields, every coverage row off
|
wordmark and from the footer URL, the header fields, every coverage row off
|
||||||
@@ -294,7 +398,20 @@ sublimit block whose amount sits after both a blank line and a page break,
|
|||||||
the excluded earthquake coverage, and the hydrometeorological deductible and
|
the excluded earthquake coverage, and the hydrometeorological deductible and
|
||||||
coinsurance pulled from their own per-zone block.
|
coinsurance pulled from their own per-zone block.
|
||||||
|
|
||||||
Four of those are regression tests for ways the parser can silently attach
|
From the three A.N.A. PDFs: brand detection (and that GMX's layout rules
|
||||||
|
cannot claim an ANA page), the header band, DD MM YYYY read out of three
|
||||||
|
separate column cells, the six money cells with `DISCOUNT` printed as a bare
|
||||||
|
`-`, the vehicle row with a one-word and a two-word `BODY` cell, the empty
|
||||||
|
TRAILER/TOWING slots, the agent street number and postal code that must *not*
|
||||||
|
be read as the policy number and clave, the declared values labelled by item
|
||||||
|
slot, the `$500.00` inside the deductible sentence that is not a sum insured,
|
||||||
|
the per-person/per-accident split in both of its printed forms, an add-on's
|
||||||
|
figure recorded as a premium, section 9's parenthesised limit, the by-the-day
|
||||||
|
term, the excluded sections, the column-position split on the driver's policy,
|
||||||
|
its different section order, and — for both faces — that a doubled or tripled
|
||||||
|
input yields one set of coverages and one driver rather than one per copy.
|
||||||
|
|
||||||
|
Four of the GMX cases are regression tests for ways the parser can silently attach
|
||||||
the *wrong* value rather than none — a neighbouring coverage's prose read as
|
the *wrong* value rather than none — a neighbouring coverage's prose read as
|
||||||
a deductible, the page-level `DEDUCIBLES:` paragraph read as one, a coverage
|
a deductible, the page-level `DEDUCIBLES:` paragraph read as one, a coverage
|
||||||
named after a wrapped prose tail, and one section's per-zone deductible
|
named after a wrapped prose tail, and one section's per-zone deductible
|
||||||
@@ -303,10 +420,10 @@ the parser against the full ten-page document.
|
|||||||
|
|
||||||
## Not built
|
## Not built
|
||||||
|
|
||||||
- **Only GMX.** The dispatcher (`detectPolicyProvider`) is a table of
|
- **GMX and A.N.A. only.** The dispatcher (`detectPolicyProvider`) is a table
|
||||||
`[provider, pattern]` pairs plus a `parsers` map, so adding ANA or Qualitas
|
of `[provider, pattern]` pairs plus a `parsers` map, so adding Qualitas is a
|
||||||
is a parser function and two entries — but no other carrier's layout has
|
parser function and two entries — but no other carrier's layout has been
|
||||||
been seen yet, and guessing at one produces a parser nobody can verify.
|
seen yet, and guessing at one produces a parser nobody can verify.
|
||||||
- **The `recibo` PDF.** Reading the premium off GMX's separate receipt
|
- **The `recibo` PDF.** Reading the premium off GMX's separate receipt
|
||||||
document, and pairing it to the certificate it belongs to, is the obvious
|
document, and pairing it to the certificate it belongs to, is the obvious
|
||||||
next piece. It is what would let `postPremium` stop being a manual tick.
|
next piece. It is what would let `postPremium` stop being a manual tick.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- ANA Seguros policy OCR: the vehicle/driver tables and the printed term in
|
||||||
|
-- days that ANA's tourist book carries and GMX's property book does not.
|
||||||
|
ALTER TABLE `policy_ocr_documents`
|
||||||
|
ADD COLUMN `extractedCoveragePeriodDays` INTEGER NULL,
|
||||||
|
ADD COLUMN `extractedVehiclesJson` JSON NULL,
|
||||||
|
ADD COLUMN `extractedDriversJson` JSON NULL;
|
||||||
|
|
||||||
|
-- The parser note trail outgrew VARCHAR(191): a nine-section ANA policy runs
|
||||||
|
-- past it routinely, and the notes that got cut were the tail ones — the
|
||||||
|
-- "could not read X" warnings the reviewer most needs.
|
||||||
|
ALTER TABLE `policy_ocr_documents`
|
||||||
|
MODIFY COLUMN `matchNote` TEXT NULL;
|
||||||
@@ -379,8 +379,11 @@ model PolicyDocument {
|
|||||||
/// source PDF and optionally writes a premium Transaction.
|
/// source PDF and optionally writes a premium Transaction.
|
||||||
model PolicyOcrBatch {
|
model PolicyOcrBatch {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
/// Which insurance provider portal the batch came from. "GMX" today;
|
/// Which insurance provider portal the batch came from — "GMX", "ANA", or
|
||||||
/// future providers (AXA, GNP, …) extend the parser, not this table.
|
/// "GMX + ANA" when one upload mixed them. Set by the pipeline from what
|
||||||
|
/// the parsers actually claimed, not asked of the uploader, so it can
|
||||||
|
/// never contradict the documents. Future providers extend the parser,
|
||||||
|
/// not this table.
|
||||||
provider String @default("GMX")
|
provider String @default("GMX")
|
||||||
status PolicyOcrBatchStatus @default(UPLOADED)
|
status PolicyOcrBatchStatus @default(UPLOADED)
|
||||||
uploadedById String
|
uploadedById String
|
||||||
@@ -442,10 +445,22 @@ model PolicyOcrDocument {
|
|||||||
extractedBrokerFee Decimal? @db.Decimal(12, 2)
|
extractedBrokerFee Decimal? @db.Decimal(12, 2)
|
||||||
extractedTotal Decimal? @db.Decimal(12, 2)
|
extractedTotal Decimal? @db.Decimal(12, 2)
|
||||||
/// Per-coverage rows from the GMX "Material damages" / "Additional risk"
|
/// Per-coverage rows from the GMX "Material damages" / "Additional risk"
|
||||||
/// tables — preserved verbatim so a missing premium receipt still leaves
|
/// tables and ANA's numbered risk sections — preserved verbatim so a
|
||||||
/// the coverages auditable.
|
/// missing premium receipt still leaves the coverages auditable.
|
||||||
extractedCoveragesJson Json?
|
extractedCoveragesJson Json?
|
||||||
extractedPremiumPayment String?
|
extractedPremiumPayment String?
|
||||||
|
/// Printed term length. ANA sells 3- and 4-day tourist policies, so
|
||||||
|
/// leaving `Policy.coveragePeriodDays` at its 365 default would overstate
|
||||||
|
/// a weekend policy by a year.
|
||||||
|
extractedCoveragePeriodDays Int?
|
||||||
|
/// `ParsedVehicle[]` off ANA's ITEM/YEAR/MAKE/BODY/SERIAL/PLATES table.
|
||||||
|
/// Written to `Vehicle` rows on confirm; kept here so the review screen
|
||||||
|
/// shows what was read before anything is applied.
|
||||||
|
extractedVehiclesJson Json?
|
||||||
|
/// `ParsedDriver[]` — the insured on ANA's automobile face, the numbered
|
||||||
|
/// POLICY HOLDER list on its driver's policy. Written to `InsuredDriver`
|
||||||
|
/// rows on confirm.
|
||||||
|
extractedDriversJson Json?
|
||||||
|
|
||||||
// Match by `Policy.policyNumber` → existing Policy / Customer.
|
// Match by `Policy.policyNumber` → existing Policy / Customer.
|
||||||
matchedPolicyId String?
|
matchedPolicyId String?
|
||||||
@@ -456,7 +471,11 @@ model PolicyOcrDocument {
|
|||||||
/// normal; >1 means the policy number is shared across customers and a
|
/// normal; >1 means the policy number is shared across customers and a
|
||||||
/// human must pick.
|
/// human must pick.
|
||||||
matchCandidates Json?
|
matchCandidates Json?
|
||||||
matchNote String?
|
/// Text, not VARCHAR(191): this carries the parser's whole note trail, and
|
||||||
|
/// a multi-section ANA policy runs past 191 characters routinely. Silently
|
||||||
|
/// truncating it drops the tail notes, which are the ones that say what
|
||||||
|
/// could NOT be read.
|
||||||
|
matchNote String? @db.Text
|
||||||
|
|
||||||
reviewedById String?
|
reviewedById String?
|
||||||
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
|
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
|
||||||
|
|||||||
Reference in New Issue
Block a user