GMX ships two unrelated documents for the same policy and the office downloads both from the same portal. The parser only knew the English caratula, so a `…-CondicionesParticulares.pdf` parsed to an almost entirely empty row — including the policy number, which the matcher needs. `parseGmx` becomes a dispatcher over `parseGmxCaratula` (unchanged behaviour) and the new `parseGmxEspecificacion`. Both still report `provider: "GMX"`: the matcher keys on the policy number alone and must not care which artifact was uploaded. The especificación has no tables. Coverages are found by anchoring on `Límite … Responsabilidad:` and walking backwards for the heading, where a heading is a short line *preceded by a blank line* — length alone cannot tell one from the wrapped tail of the paragraph above it, and without that condition coverages get named after the last word of the preceding prose. Also fixed, both pre-existing: - The policy number's group widths are not the same across the two families (`007-037-…-0000-02` vs `07-037-…-00000-01`). The pinned-width regex is replaced by a shape, so both read. - The caratula's ZIP fallback pushed a note saying it had read the ZIP from the address, then never assigned it. Verified against the full ten-page real document: all 17 coverages, amounts, deductibles and the excluded earthquake section match what is printed. 24 parser tests (was 8), four of them regressions for ways this layout can silently attach the *wrong* value rather than none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1020 lines
40 KiB
TypeScript
1020 lines
40 KiB
TypeScript
import type { OcrPage } from "../../statements/ocr/ocr.provider";
|
|
|
|
/**
|
|
* What one parsed policy page yields. All fields are nullable because each
|
|
* provider prints a different subset (GMX's certificate has no premium
|
|
* breakdown, only insured amounts; GMX's receipt page would carry the
|
|
* premium), and the matcher + the review queue both work better with
|
|
* "field was read" vs "field was not" rather than guessing.
|
|
*/
|
|
export interface ParsedPolicy {
|
|
/** "GMX" today; the dispatcher lives on `detectProvider`. */
|
|
provider: string;
|
|
policyNumber: string | null;
|
|
insuredName: string | null;
|
|
additionalInsured: string | null;
|
|
/** The "Broker" line on GMX — mapped onto `Policy.agentName`. */
|
|
agentName: string | null;
|
|
legalAddress: string | null;
|
|
zip: string | null;
|
|
policyFrom: Date | null;
|
|
policyTo: Date | null;
|
|
/** Signature/issue date — `Policy.policyDate`. */
|
|
policyDate: Date | null;
|
|
/** "MXN" | "USD" | …, derived from the printed currency word. */
|
|
currency: string | null;
|
|
netPremium: number | null;
|
|
policyFee: number | null;
|
|
brokerFee: number | null;
|
|
total: number | null;
|
|
/** "CONTADO" / "MENSUAL" / … — premium-payment cadence text. */
|
|
premiumPayment: string | null;
|
|
/**
|
|
* GMX prints per-coverage rows in a table: Building / Contents /
|
|
* Earthquake / … with insured amount, deductible, loss participation.
|
|
* Preserved verbatim so a missing premium receipt still leaves the
|
|
* coverages auditable on the Policy row.
|
|
*/
|
|
coverages: ParsedCoverage[];
|
|
/** Human-readable trail of what was read, surfaced in the review queue. */
|
|
notes: string[];
|
|
}
|
|
|
|
export interface ParsedCoverage {
|
|
/** "Building", "Contents", "Debris removal Building", "Earthquake…". */
|
|
risk: string;
|
|
insuredAmount: number | null;
|
|
deductible: string | null;
|
|
lossParticipation: string | null;
|
|
}
|
|
|
|
// --- shared helpers ---------------------------------------------------------
|
|
|
|
const DIGIT_CONFUSIONS: Record<string, string> = {
|
|
O: "0", o: "0", D: "0", I: "1", l: "1", "|": "1", S: "5", B: "8",
|
|
};
|
|
|
|
/**
|
|
* Tesseract confuses these glyphs inside numeric runs with some regularity.
|
|
* Same map and same caveat as the statement parser: ONLY apply to fields
|
|
* known to be digits, never to free text.
|
|
*/
|
|
function toDigits(s: string | null | undefined): string {
|
|
if (!s) return "";
|
|
return s
|
|
.split("")
|
|
.map((c) => DIGIT_CONFUSIONS[c] ?? c)
|
|
.join("")
|
|
.replace(/\D/g, "");
|
|
}
|
|
|
|
/**
|
|
* Parse a printed amount, treating `,` and `.` by position rather than by
|
|
* assumption. Same algorithm as the statement parser — kept here so the
|
|
* policy module is self-contained, since importing from `../../statements`
|
|
* would couple two unrelated domains through a helper.
|
|
*/
|
|
function money(s: string | null | undefined): number | null {
|
|
if (!s) return null;
|
|
const cleaned = s.replace(/[\s$]/g, "");
|
|
|
|
let m = cleaned.match(/^(\d{1,3}(?:[.,]\d{3})+)([.,]\d{1,2})?$/);
|
|
if (m) {
|
|
const whole = m[1].replace(/[.,]/g, "");
|
|
const cents = m[2] ? m[2].slice(1) : "";
|
|
return Number(cents ? `${whole}.${cents.padEnd(2, "0")}` : whole);
|
|
}
|
|
|
|
m = cleaned.match(/^(\d+)[.,](\d{2})$/);
|
|
if (m) return Number(`${m[1]}.${m[2]}`);
|
|
|
|
const n = Number(cleaned.replace(/[,.]/g, ""));
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
function firstMatch(text: string, patterns: RegExp[]): string | null {
|
|
for (const p of patterns) {
|
|
const m = text.match(p);
|
|
if (m?.[1]) return m[1].trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function allMatches(text: string, pattern: RegExp): string[] {
|
|
const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
const out: string[] = [];
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(text)) !== null) {
|
|
if (m[1]) out.push(m[1]);
|
|
if (m.index === re.lastIndex) re.lastIndex++;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The printed policy number, as a shape rather than as fixed widths.
|
|
*
|
|
* Both GMX document families print the same five dash-separated groups, but
|
|
* NOT with the same widths: the English caratula reads
|
|
* `007-037-07005947-0000-02` and the Spanish PVL especificación reads
|
|
* `07-037-07006957-00000-01` — 2 digits in the first group and 5 in the
|
|
* fourth. Pinning the widths (as the first version of this parser did) reads
|
|
* one family and silently returns null on the other, which the matcher then
|
|
* reports as "no se pudo leer el número de póliza" on a page where the number
|
|
* is printed ten times.
|
|
*
|
|
* The OCR confusion class is allowed inside the groups but NOT normalised
|
|
* here — `normalizePolicyNumber` does that, so the raw read stays available.
|
|
*/
|
|
const POLICY_NUMBER_SHAPE =
|
|
/\b([0-9OIlSBD]{2,4}[-\s][0-9OIlSBD]{3}[-\s][0-9OIlSBD]{6,10}[-\s][0-9OIlSBD]{4,6}[-\s][0-9OIlSBD]{2})\b/;
|
|
|
|
/**
|
|
* Strip layout whitespace and fold the Tesseract digit confusions, keeping the
|
|
* dashes: they are part of the number as the office keys it, and
|
|
* `PolicyMatcherService` matches `Policy.policyNumber` exactly.
|
|
*/
|
|
function normalizePolicyNumber(raw: string | null | undefined): string | null {
|
|
if (!raw) return null;
|
|
const out = raw
|
|
.replace(/\s+/g, "")
|
|
.split("")
|
|
.map((c) => (c === "-" ? c : (DIGIT_CONFUSIONS[c] ?? c)))
|
|
.join("");
|
|
return out || null;
|
|
}
|
|
|
|
const MONTHS: Record<string, number> = {
|
|
ENE: 0, FEB: 1, MAR: 2, ABR: 3, MAY: 4, JUN: 5,
|
|
JUL: 6, AGO: 7, SEP: 8, OCT: 9, NOV: 10, DIC: 11,
|
|
};
|
|
|
|
/**
|
|
* DD/MM/YYYY (GMX) and the dash-separated ISO variants. Two-digit years are
|
|
* windowed: < 50 → 20YY, ≥ 50 → 19YY, matching what a 1950-2049 window
|
|
* expects from a paper document.
|
|
*/
|
|
function parseDate(raw: string | null | undefined): Date | null {
|
|
if (!raw) return null;
|
|
const s = raw.trim();
|
|
|
|
let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
if (m) return utc(+m[3], +m[2] - 1, +m[1]);
|
|
|
|
m = s.match(/^(\d{1,2})[-\s/]([A-Z]{3})[-\s/](\d{2,4})$/i);
|
|
if (m && MONTHS[m[2].toUpperCase()] !== undefined) {
|
|
const yr = +m[3];
|
|
const y = m[3].length === 2 ? (yr < 50 ? 2000 + yr : 1900 + yr) : yr;
|
|
return utc(y, MONTHS[m[2].toUpperCase()], +m[1]);
|
|
}
|
|
|
|
m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
|
|
if (m) return utc(+m[1], +m[2] - 1, +m[3]);
|
|
|
|
// "July 23, 2026" — the signature date on the GMX certificate.
|
|
m = s.match(/^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/);
|
|
if (m) {
|
|
const MONTH_NAMES: Record<string, number> = {
|
|
january: 0, february: 1, march: 2, april: 3, may: 4, june: 5,
|
|
july: 6, august: 7, september: 8, october: 9, november: 10, december: 11,
|
|
};
|
|
const mo = MONTH_NAMES[m[1].toLowerCase()];
|
|
if (mo !== undefined) return utc(+m[3], mo, +m[2]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function utc(y: number, mo: number, d: number): Date | null {
|
|
const dt = new Date(Date.UTC(y, mo, d));
|
|
return Number.isNaN(dt.getTime()) ? null : dt;
|
|
}
|
|
|
|
/** Map the printed currency word onto an ISO code. */
|
|
function currencyCode(raw: string | null | undefined): string | null {
|
|
if (!raw) return null;
|
|
const s = raw.trim().toUpperCase();
|
|
if (s.startsWith("PESO") || s === "MXN" || s.includes("NACIONAL")) return "MXN";
|
|
if (s.startsWith("DOLAR") || s === "USD" || s.includes("DOLLAR")) return "USD";
|
|
if (s === "EUR" || s.includes("EURO")) return "EUR";
|
|
return null;
|
|
}
|
|
|
|
// --- provider detection -----------------------------------------------------
|
|
|
|
/**
|
|
* Brand first, layout as a fallback. Same ordering rule as the statement
|
|
* parser: a brand wordmark is the cheapest, most reliable discriminator, and
|
|
* a layout rule that runs first can wrongly claim a page that happens to
|
|
* carry the same shape string (the statement parser's lesson with CFE vs
|
|
* GAS on "PERIODO FACTURADO").
|
|
*/
|
|
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],
|
|
];
|
|
|
|
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,
|
|
],
|
|
];
|
|
|
|
export function detectPolicyProvider(text: string): string | null {
|
|
for (const group of [BRAND, LAYOUT]) {
|
|
for (const [name, pattern] of group) {
|
|
if (pattern.test(text)) return name;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- parsers ----------------------------------------------------------------
|
|
|
|
const PARSERS: Record<string, (page: OcrPage) => ParsedPolicy> = {
|
|
GMX: parseGmx,
|
|
};
|
|
|
|
const EMPTY_COVERAGE: ParsedCoverage = {
|
|
risk: "",
|
|
insuredAmount: null,
|
|
deductible: null,
|
|
lossParticipation: null,
|
|
};
|
|
|
|
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 PARSERS[provider](page);
|
|
}
|
|
|
|
// --- GMX --------------------------------------------------------------------
|
|
|
|
/**
|
|
* GMX ships (at least) two unrelated document families, and the office
|
|
* downloads both from the same portal for the same policy:
|
|
*
|
|
* 1. **Caratula / translation** (`…_Traduccion.pdf`) — the English-labelled
|
|
* certificate: a boxed header table (Policy / Insured / Broker / From / To
|
|
* / Currency) and a four-column coverage table.
|
|
* 2. **Especificación** (`…-CondicionesParticulares.pdf`) — the Spanish
|
|
* "ESPECIFICACIÓN QUE SE ADHIERE Y FORMA PARTE INTEGRANTE DE LA PÓLIZA"
|
|
* issued by GMX's Punto de Venta en Línea (PVL). Ten pages of prose with
|
|
* NO header table, NO dates, NO broker, and the coverages expressed as
|
|
* section headings above a `Límite Máximo de Responsabilidad:` label.
|
|
*
|
|
* They share only the brand and the policy number, so they get separate
|
|
* parsers behind one provider. Both still return `provider: "GMX"` — the
|
|
* matcher keys on the policy number alone and must not care which artifact
|
|
* the office happened to upload.
|
|
*/
|
|
function parseGmx(page: OcrPage): ParsedPolicy {
|
|
return isEspecificacion(page.text) ? parseGmxEspecificacion(page) : parseGmxCaratula(page);
|
|
}
|
|
|
|
/** The PVL especificación announces itself in its own repeated page header. */
|
|
function isEspecificacion(text: string): boolean {
|
|
return /ESPECIFICACI[ÓO]N\s+QUE\s+SE\s+ADHIERE|PVL\s+Hogar|Nombre\s+del\s+asegurado/i.test(text);
|
|
}
|
|
|
|
/**
|
|
* GMX policy certificate layout (this is the translation PDF — the Spanish
|
|
* version is the canonical source, but every GMX portal download is a
|
|
* translation so the parser can rely on these English labels).
|
|
*
|
|
* Page 1 carries the contract header in a single boxed table:
|
|
* Policy | Insured | Additional insured | Legal address | ZIP | Income Tax No.
|
|
* Broker | Term | From | To | Currency | Premium payment
|
|
* followed by an "Agreed clauses" block, the signature date, and the GMX
|
|
* letterhead.
|
|
*
|
|
* Page 2 carries the per-coverage table (Risk / Insured Amount / Deductible /
|
|
* Loss Participation) under "Material damages Section" and "ADDITIONAL RISK".
|
|
*
|
|
* Premium / total / fees are NOT on the certificate page — they live on
|
|
* GMX's separate "recibo" PDF. The parser leaves them null and flags the
|
|
* gap in `notes`; the matcher still proposes a Policy update from the
|
|
* certificate alone, and the staff confirm step fills premium in by hand
|
|
* or after a follow-up receipt upload.
|
|
*/
|
|
function parseGmxCaratula(page: OcrPage): ParsedPolicy {
|
|
const text = page.text;
|
|
const notes: string[] = [];
|
|
|
|
// ----- header table (page 1) --------------------------------------------
|
|
// The Policy row repeats the number in a long run:
|
|
// "Policy 007-037-07005947-0000-02 in accordance with the enclosed clauses…"
|
|
// so taking the first token-shaped number is correct; the trailing prose
|
|
// never looks like one. The dashes are part of the printed number — keep
|
|
// them (don't run toDigits, which would flatten them).
|
|
const policyNumber = firstMatch(text, [
|
|
new RegExp(`\\bPolicy\\s+${POLICY_NUMBER_SHAPE.source}`, "i"),
|
|
/\bPolicy\s+([0-9OIlSBD][0-9OIlSBD\s-]{9,30})/,
|
|
]);
|
|
|
|
// "Insured JON ASHLEY STRABALA" — label, then 1+ whitespace, then the name.
|
|
// Names can carry accents (ÁVILA) or apostrophes (O'NEILL); the label is
|
|
// always upper-case English on this layout, so case is reliable.
|
|
const insuredName = labelValue(text, /^Insured\s+([A-ZÁÉÍÓÚÑ'][A-ZÁÉÍÓÚÑ '\-.]+)$/m);
|
|
const additionalInsured = labelValue(text, /^Additional\s+insured\s+([A-ZÁÉÍÓÚÑ '\-.]+)$/m);
|
|
|
|
// Legal address is a single long line; the parser keeps it whole.
|
|
const legalAddress = labelValue(text, /^Legal\s+address\s+(.+)$/m);
|
|
let zip = labelValue(text, /^ZIP\s+(\d{4,6})\b/m);
|
|
if (!zip && legalAddress) {
|
|
// Last resort: zip often appears at the tail of the address run too
|
|
// ("…C.P. 22550"). Cheap regex, no false-positive cost on this layout.
|
|
const m = legalAddress.match(/\b(\d{5})\b(?!.*\b\d{5}\b)/);
|
|
if (m) {
|
|
zip = m[1];
|
|
notes.push(`ZIP leído de la dirección (${m[1]})`);
|
|
}
|
|
}
|
|
|
|
// Broker line on GMX: "(1176) Jorge Humberto Cuadros" — the number is the
|
|
// agent code, the name is what lands on `Policy.agentName`. The parens
|
|
// are optional: a future layout or scan drop them.
|
|
const brokerRaw = labelValue(text, /^Broker\s+(?:\(\d+\)\s*)?(.+)$/m);
|
|
const agentName = brokerRaw?.trim() ?? null;
|
|
|
|
// Term: "12 months" — informational, not a free-standing date. Stored in
|
|
// notes; the UI can derive `coveragePeriodDays` from From/To anyway.
|
|
const term = firstMatch(text, [/^Term\s+(\d+\s+months?)$/m]);
|
|
if (term) notes.push(`vigencia: ${term}`);
|
|
|
|
const policyFrom = parseDate(
|
|
labelValue(text, /^From\s+(\d{1,2}\/\d{1,2}\/\d{4})\b/m),
|
|
);
|
|
const policyTo = parseDate(
|
|
firstMatch(text, [/^To\s+(\d{1,2}\/\d{1,2}\/\d{4})\b/m]),
|
|
);
|
|
|
|
// "at twelve hours (noon) Mexico City time." — kept in notes only.
|
|
if (/twelve\s*hours|noon/i.test(text)) notes.push("vencimiento a las 12:00 hora del centro");
|
|
|
|
// The Currency / Premium payment cells sit next to each other on one
|
|
// line; pull them with bounded matches so the trailing label of the
|
|
// adjacent cell doesn't swallow the wrong value.
|
|
const currency = currencyCode(labelValue(text, /^Currency\s+(\S+?)(?:\s+Premium\s+payment|$)/m));
|
|
const premiumPayment = labelValue(text, /Premium\s+payment\s+(\S+)$/m);
|
|
|
|
// ----- signature date (page 1) -----------------------------------------
|
|
// Appears above the signature line on its own: "July 23, 2026".
|
|
const dateMatch = text.match(
|
|
/\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s*\d{4}\b/,
|
|
);
|
|
const policyDate = dateMatch ? parseDate(dateMatch[0]) : null;
|
|
if (!policyDate) notes.push("no se pudo leer la fecha de firma");
|
|
|
|
// ----- coverages table (page 2) -----------------------------------------
|
|
const coverages = parseGmxCoverages(text, notes);
|
|
|
|
if (!policyNumber) notes.push("no se pudo leer el número de póliza");
|
|
if (!policyFrom || !policyTo) notes.push("no se pudo leer el período de vigencia");
|
|
// Premium fields are expected to be missing on the certificate page; flag
|
|
// it explicitly so the reviewer knows to look for a separate receipt.
|
|
if (!text.match(/Prima\s*neta|net\s*premium/i)) {
|
|
notes.push("esta página no trae prima; revisar el recibo de GMX por separado");
|
|
}
|
|
|
|
return {
|
|
provider: "GMX",
|
|
policyNumber: normalizePolicyNumber(policyNumber),
|
|
insuredName,
|
|
additionalInsured,
|
|
agentName,
|
|
legalAddress,
|
|
zip,
|
|
policyFrom,
|
|
policyTo,
|
|
policyDate,
|
|
currency,
|
|
netPremium: null,
|
|
policyFee: null,
|
|
brokerFee: null,
|
|
total: null,
|
|
premiumPayment,
|
|
coverages,
|
|
notes,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* itself rather than across the page, so a label that also appears in body
|
|
* text can't accidentally claim a different cell.
|
|
*/
|
|
function labelValue(text: string, pattern: RegExp): string | null {
|
|
const m = text.match(pattern);
|
|
if (!m?.[1]) return null;
|
|
return m[1].replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
/**
|
|
* Walk the GMX per-coverage table on page 2.
|
|
*
|
|
* Real sample row (single-line representation of the table after pdftotext
|
|
* flattens it; the real layout uses fixed columns):
|
|
* "Building $350,000.00 Not applies Not applies"
|
|
*
|
|
* The four columns are:
|
|
* Risk (left), Insured Amount ($ figure OR the word "Covered"),
|
|
* Deductible (free text — "Not applies", "5%", "2% of the sum insured…"),
|
|
* Loss Participation (same).
|
|
*
|
|
* "Covered" means the coverage is included with no dollar cap. We record
|
|
* the word so the review queue surfaces it instead of inventing a number.
|
|
*
|
|
* Deductible / Loss Participation are kept as printed strings, not
|
|
* converted to numbers — a "20%" loss participation is a different field
|
|
* shape from a "$5,000" deductible and the JSON column lets the UI render
|
|
* either verbatim.
|
|
*
|
|
* Multi-line cells (the "Earthquake" row's deductible wraps to three lines
|
|
* because the column is narrow) are collapsed by joining consecutive
|
|
* non-table-body lines onto the previous row's deductible cell before
|
|
* applying the column regex.
|
|
*/
|
|
function parseGmxCoverages(text: string, notes: string[]): ParsedCoverage[] {
|
|
const out: ParsedCoverage[] = [];
|
|
|
|
// Stop at "VALUES ADDED" — the trailing prose section (homeowner
|
|
// services, legal text) is not a coverage table. Re-enter at
|
|
// "ADDITIONAL RISK" for the second coverage block on page 2.
|
|
const segments = text.split(/VALUES\s*ADDED/i)[0].split(/ADDITIONAL\s*RISK/i);
|
|
|
|
// `[ \t]` (not `\s`) inside a cell: the deductible/loss-participation
|
|
// columns may wrap onto several lines in the raw `pdftotext` output, and
|
|
// matching across newlines silently swallows the next row.
|
|
const re = /^([A-Za-zÁÉÍÓÚÑ][A-Za-zÁÉÍÓÚÑ /\-.]+?)[ \t]+(\$[\d,.]+|Covered|Not[ \t]+applies)[ \t]+(\S+(?:[ \t]\S+){0,8})[ \t]+(\S+(?:[ \t]\S+){0,8})[ \t]*$/gim;
|
|
let m: RegExpExecArray | null;
|
|
for (const seg of segments) {
|
|
re.lastIndex = 0;
|
|
while ((m = re.exec(seg)) !== null) {
|
|
const risk = m[1].trim();
|
|
const amountCell = m[2].trim();
|
|
const deductible = m[3].trim();
|
|
const lossParticipation = m[4].trim();
|
|
|
|
// Skip the "Risk / Insured Amount / Deductible / Loss Participation"
|
|
// header row itself, which matches the same regex.
|
|
if (/^Risk$/i.test(risk) && /Insured\s*Amount/i.test(amountCell)) continue;
|
|
|
|
out.push({
|
|
risk,
|
|
insuredAmount:
|
|
amountCell === "Covered" || amountCell === "Not applies"
|
|
? null
|
|
: money(amountCell),
|
|
deductible,
|
|
lossParticipation,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (out.length === 0) notes.push("no se encontraron coberturas en la tabla");
|
|
return out;
|
|
}
|
|
|
|
// --- GMX / PVL especificación -----------------------------------------------
|
|
|
|
/**
|
|
* Page furniture on the PVL especificación: the two-line header that repeats
|
|
* on all ten pages (title + policy number on its own line) and the footer.
|
|
* Skipped when hunting for a coverage's heading, or the heading found would
|
|
* be the page break rather than the risk.
|
|
*/
|
|
const ESPEC_FURNITURE =
|
|
/^(ESPECIFICACI[ÓO]N\s+QUE\s+SE\s+ADHIERE|PVL\s+Hogar\s*-\s*GMX|P[áa]gina\s*:|La\s+presente\s+p[óo]liza\s+queda\s+sujeta)/i;
|
|
|
|
/**
|
|
* Labels that structure a coverage block rather than name one. A heading walk
|
|
* that accepts these ends up calling every coverage "Deducible:".
|
|
*/
|
|
const ESPEC_STRUCTURAL =
|
|
/^(L[íi]mites?\b|Deducibles?\b|Coaseguros?\b|Bienes\s+cubiertos|Riesgos\s+cubiertos|Riesgos\s+adicionales|Exclusiones?\b|Zona\s*:|Periodo\s+de\s+indemnizaci[óo]n|Nota\s+aclaratoria|CONDICIONES\s+ESPECIALES|IMPORTANTE\b|SECCI[ÓO]N\b|II+\.-|[●•]|\d+\.\s)/i;
|
|
|
|
/** `$200,000.00 USD`, `$ 35,050.00 M.N.` — amount plus its printed currency. */
|
|
const ESPEC_AMOUNT = /\$\s*([\d,]+(?:\.\d{2})?)\s*(USD|MXN|M\.?\s?N\.?|D[LlÓó]{1,3}[Ss]?)?/;
|
|
|
|
/** The label that introduces every insured amount on this layout. */
|
|
const ESPEC_LIMIT_LABEL = /^\s*L[íi]mite\s+(?:M[áa]ximo\s+)?(?:de\s+)?Responsabilidad\s*:?(.*)$/i;
|
|
|
|
/**
|
|
* GMX's PVL "especificación" layout — ten pages of Spanish prose that the
|
|
* office receives as `…-CondicionesParticulares.pdf`.
|
|
*
|
|
* What this document DOES carry: the policy number (printed on every page
|
|
* header), the insured's name, the risk location, and every coverage limit.
|
|
*
|
|
* What it does NOT carry, at all: policy dates, broker, premium, currency as
|
|
* a labelled field, or a coverage table. There is nothing to read for those,
|
|
* so they stay null and the notes say why — the caratula is the document that
|
|
* has them, and the office downloads both for the same policy.
|
|
*
|
|
* Coverages are not tabular here. Each one is a section heading followed by
|
|
* `Límite Máximo de Responsabilidad:` and an amount on the next line (or two
|
|
* amounts under `Edificio` / `Contenidos` sub-labels), with the deductible
|
|
* printed further down its own block. So the parser anchors on the limit
|
|
* label and walks BACKWARDS for the heading, rather than trying to match a
|
|
* row shape that does not exist.
|
|
*/
|
|
function parseGmxEspecificacion(page: OcrPage): ParsedPolicy {
|
|
const text = page.text;
|
|
const lines = text.split("\n");
|
|
const notes: string[] = [];
|
|
|
|
// ----- policy number -----------------------------------------------------
|
|
// Printed on its own line under the title on all ten pages. Ten independent
|
|
// readings of the same number is a free cross-check: on a scan, a group that
|
|
// OCR'd two different ways means one reading is wrong and there is no third
|
|
// to break the tie, so the row goes to a human (the same rule the zona
|
|
// federal statement parser applies to its clave).
|
|
const readings = [
|
|
...new Set(
|
|
allMatches(text, POLICY_NUMBER_SHAPE)
|
|
.map(normalizePolicyNumber)
|
|
.filter((n): n is string => n != null),
|
|
),
|
|
];
|
|
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(
|
|
`el número de póliza se leyó de ${readings.length} formas distintas (${readings.join(", ")})`,
|
|
);
|
|
}
|
|
|
|
// ----- insured + risk location ------------------------------------------
|
|
// Values wrap onto the following lines within the same visual cell, and a
|
|
// blank line always ends the cell, so the block reader joins until one.
|
|
const insuredName = espectBlock(lines, /^\s*Nombre\s+del\s+asegurado\s+(.+)$/i);
|
|
const additionalInsured = espectBlock(lines, /^\s*Asegurado\s+Adicional\s+(\S.*)$/i);
|
|
const legalAddress = espectBlock(lines, /^\s*Ubicaci[óo]n\s+del\s+riesgo\s+(.+)$/i);
|
|
|
|
// No ZIP field on this layout; it is the trailing run of the address
|
|
// ("…BAJA CALIFORNIA 22713"). Take the LAST five-digit run — a street
|
|
// number earlier in the line is never five digits, but anchoring on the
|
|
// first match would still be the fragile choice.
|
|
let zip: string | null = null;
|
|
if (legalAddress) {
|
|
const m = legalAddress.match(/(\d{5})(?!.*\d{5})/);
|
|
if (m) {
|
|
zip = m[1];
|
|
notes.push(`código postal leído de la ubicación del riesgo (${m[1]})`);
|
|
}
|
|
}
|
|
|
|
// ----- underwriting context ---------------------------------------------
|
|
// Not `ParsedPolicy` fields, but they are the difference between a coverage
|
|
// a reviewer can price and one they cannot, so they ride along in notes.
|
|
const personType = espectBlock(lines, /^\s*Tipo\s+Persona\s+Asegurada\s+(.+)$/i);
|
|
if (personType) notes.push(`tipo de persona asegurada: ${personType}`);
|
|
const building = espectBlock(lines, /^\s*Caracter[íi]sticas\s+del\s+Inmueble\s+(.+)$/i);
|
|
if (building) notes.push(`características del inmueble: ${building}`);
|
|
const water = firstMatch(text, [/^\s*(-?\s*\d+\s*mts\.?\s*cuerpo\s+agua\s+\S+)\s*$/im]);
|
|
if (water) notes.push(`distancia a cuerpo de agua: ${water.replace(/\s+/g, " ")}`);
|
|
const generalConditions = firstMatch(text, [/^\s*(W_\S+\.pdf)\s*$/im]);
|
|
if (generalConditions) notes.push(`condiciones generales: ${generalConditions}`);
|
|
|
|
// ----- coverages ---------------------------------------------------------
|
|
const { coverages, currency } = parseEspecificacionCoverages(lines, notes);
|
|
|
|
// These are absent by design on this document, not failures to read. Say so
|
|
// explicitly, or the reviewer reads four empty fields as a broken parse.
|
|
notes.push(
|
|
"la especificación PVL no trae vigencia, agente ni prima; esos datos están en la carátula de la póliza",
|
|
);
|
|
|
|
return {
|
|
provider: "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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read a `Label Value` cell whose value wraps onto the following lines.
|
|
*
|
|
* `pdftotext -layout` renders the especificación's header cells with the
|
|
* value in a right-hand column, so a two-line risk location comes back as the
|
|
* label line plus a continuation line indented to the same column. A blank
|
|
* line always terminates the cell.
|
|
*/
|
|
function espectBlock(lines: string[], label: RegExp): string | null {
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const m = lines[i].match(label);
|
|
if (!m?.[1]) continue;
|
|
const parts = [m[1]];
|
|
for (let j = i + 1; j < lines.length && lines[j].trim(); j++) {
|
|
parts.push(lines[j].trim());
|
|
}
|
|
const value = parts.join(" ").replace(/\s+/g, " ").trim();
|
|
return value || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Walk the especificación's coverages by anchoring on the limit label.
|
|
*
|
|
* For each `Límite … Responsabilidad:` the amount is either on the same line
|
|
* ("Bienes a la Intemperie"), on the next line (most), or split across two
|
|
* lines under `Edificio` / `Contenidos` sub-labels ("Remoción de escombros").
|
|
* The risk name is the nearest heading ABOVE the label: a short line that is
|
|
* itself preceded by a blank one. That last condition is what distinguishes a
|
|
* heading from the short tail of a wrapped paragraph ("efectuados.",
|
|
* "Y CADA PÉRDIDA."), which is otherwise indistinguishable by length alone
|
|
* and would name a coverage after the last word of the prose above it.
|
|
*
|
|
* Returns the currency alongside, because on this layout there is no currency
|
|
* field — it is only ever printed next to the amounts, and the sublimits in
|
|
* the body prose are quoted in M.N. while the limits themselves are in USD.
|
|
* Only the limit amounts vote.
|
|
*/
|
|
function parseEspecificacionCoverages(
|
|
lines: string[],
|
|
notes: string[],
|
|
): { coverages: ParsedCoverage[]; currency: string | null } {
|
|
const coverages: ParsedCoverage[] = [];
|
|
const currencyVotes: Record<string, number> = {};
|
|
let lastHeading: string | null = null;
|
|
|
|
const limitLines: number[] = [];
|
|
lines.forEach((line, i) => {
|
|
if (ESPEC_LIMIT_LABEL.test(line)) limitLines.push(i);
|
|
});
|
|
|
|
limitLines.forEach((i, n) => {
|
|
const nextLimit = limitLines[n + 1] ?? lines.length;
|
|
const heading = espectHeadingAbove(lines, i);
|
|
|
|
// "Sublímites:" / "Límite adicional." head a block that qualifies the
|
|
// coverage above them rather than naming a new one, so they are read as
|
|
// a sublimit OF the previous risk instead of as a standalone coverage.
|
|
let risk = heading ?? "(sin nombre)";
|
|
if (/^Subl[íi]mites?\b|^L[íi]mite\s+adicional/i.test(risk) && lastHeading) {
|
|
risk = `${lastHeading} — sublímite`;
|
|
} else if (heading) {
|
|
lastHeading = heading;
|
|
}
|
|
if (!heading) notes.push(`un límite no tiene encabezado identificable (línea ${i + 1})`);
|
|
|
|
const deductible = espectDeductibleFor(lines, i, nextLimit);
|
|
const found = espectAmountsFor(lines, i);
|
|
|
|
if (found.length === 0) {
|
|
notes.push(`no se pudo leer el monto de "${risk}"`);
|
|
coverages.push({ risk, insuredAmount: null, deductible, lossParticipation: null });
|
|
return;
|
|
}
|
|
|
|
for (const a of found) {
|
|
if (a.currency) currencyVotes[a.currency] = (currencyVotes[a.currency] ?? 0) + 1;
|
|
coverages.push({
|
|
risk: a.sublabel ? `${risk} — ${a.sublabel}` : risk,
|
|
insuredAmount: a.amount,
|
|
deductible,
|
|
lossParticipation: null,
|
|
});
|
|
}
|
|
});
|
|
|
|
// The per-section coverages that are stated inline rather than under a
|
|
// limit label. On this policy they are the two that matter most on the
|
|
// coast — earthquake (EXCLUIDO here) and hydrometeorological — and skipping
|
|
// them would leave the biggest exposure off the parsed row entirely.
|
|
coverages.push(...espectInlineSectionRisks(lines, currencyVotes, notes));
|
|
|
|
if (coverages.length === 0) notes.push("no se encontraron coberturas");
|
|
|
|
const currency =
|
|
Object.entries(currencyVotes).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
if (currency) {
|
|
notes.push(`moneda tomada de los límites impresos (${currency})`);
|
|
} else {
|
|
notes.push("no se pudo determinar la moneda de las sumas aseguradas");
|
|
}
|
|
|
|
return { coverages, currency };
|
|
}
|
|
|
|
/**
|
|
* The nearest line above `at` that reads as a heading: short, not page
|
|
* furniture, not a structural label, and itself preceded by a blank line.
|
|
*/
|
|
function espectHeadingAbove(lines: string[], at: number): string | null {
|
|
for (let i = at - 1, walked = 0; i >= 0 && walked < 40; i--, walked++) {
|
|
if (!isEspecHeading(lines, i)) continue;
|
|
return lines[i].trim().replace(/\s*[:.]$/, "");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Does line `i` read as a coverage heading?
|
|
*
|
|
* Short, not page furniture, not a structural label — and preceded by a blank
|
|
* line, which is the condition that actually does the work: a heading sits in
|
|
* its own paragraph, and the short tail of a wrapped sentence ("efectuados.",
|
|
* "Y CADA PÉRDIDA.") does not. Length alone cannot tell those apart.
|
|
*/
|
|
function isEspecHeading(lines: string[], i: number): boolean {
|
|
const line = lines[i]?.trim() ?? "";
|
|
if (!line) return false;
|
|
if (ESPEC_FURNITURE.test(line)) return false;
|
|
// The repeated policy-number line in the page header.
|
|
if (POLICY_NUMBER_SHAPE.test(line) && line.replace(/[\d\sOIlSBD-]/g, "") === "") return false;
|
|
if (ESPEC_STRUCTURAL.test(line)) return false;
|
|
if (line.length > 60) return false;
|
|
// An amount on its own line is a value, never a heading — the sublimit
|
|
// block prints one after a blank line, exactly where a heading would sit.
|
|
if (/^\$?\s*[\d,]+(?:\.\d{2})?\s*(USD|MXN|M\.?\s?N\.?|DLLS?)?$/i.test(line)) return false;
|
|
return i > 0 && !lines[i - 1].trim();
|
|
}
|
|
|
|
/**
|
|
* Amounts belonging to one limit label: on the label line itself, then on the
|
|
* following lines up to the blank that closes the block. A short non-amount
|
|
* line inside the block is a sub-label for the amount beneath it
|
|
* (`Edificio` / `Contenidos`).
|
|
*/
|
|
function espectAmountsFor(
|
|
lines: string[],
|
|
at: number,
|
|
): { amount: number | null; currency: string | null; sublabel: string | null }[] {
|
|
const out: { amount: number | null; currency: string | null; sublabel: string | null }[] = [];
|
|
|
|
const push = (line: string, sublabel: string | null) => {
|
|
const m = line.match(ESPEC_AMOUNT);
|
|
if (!m) return false;
|
|
out.push({ amount: money(m[1]), currency: espectCurrency(m[2]), sublabel });
|
|
return true;
|
|
};
|
|
|
|
const tail = lines[at].match(ESPEC_LIMIT_LABEL)?.[1]?.trim() ?? "";
|
|
if (tail && push(tail, null)) return out;
|
|
|
|
let sublabel: string | null = null;
|
|
let seenContent = false;
|
|
for (let i = at + 1; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
// A blank line closes the block — but only once the block has started.
|
|
// The label and its amount are not always adjacent: the sublimit block on
|
|
// page 7 renders as "Límite de Responsabilidad:", a blank line, then
|
|
// "$2,000.00 USD", and breaking on the first blank drops that amount.
|
|
if (!line) {
|
|
if (seenContent) break;
|
|
if (i - at > 3) break;
|
|
continue;
|
|
}
|
|
if (ESPEC_FURNITURE.test(line)) continue;
|
|
if (push(line, sublabel)) {
|
|
sublabel = null;
|
|
seenContent = true;
|
|
continue;
|
|
}
|
|
seenContent = true;
|
|
// Not an amount: a sub-label if it is short enough to be one.
|
|
if (line.length <= 40 && !ESPEC_STRUCTURAL.test(line)) sublabel = line.replace(/\s*:$/, "");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The deductible printed inside this coverage's own block, i.e. between its
|
|
* limit and the next one.
|
|
*
|
|
* The value follows a bare `Deducible:` on the next line ("Sin deducible").
|
|
* The length guard is load-bearing: the document also has a page-level
|
|
* `DEDUCIBLES:` heading followed by a paragraph explaining how deductibles
|
|
* interact with the inflation clause, and without the guard that paragraph
|
|
* becomes the deductible of whichever coverage precedes it.
|
|
*/
|
|
function espectDeductibleFor(lines: string[], from: number, to: number): string | null {
|
|
for (let i = from + 1; i < to && i < from + 30; i++) {
|
|
const line = lines[i].trim();
|
|
// A heading ends this coverage's block. Not every block that follows a
|
|
// limit carries one of its own — the per-zone `Fenómenos
|
|
// hidrometeorológicos` deductible table has no limit label to stop at, so
|
|
// without this the coverage above it silently adopts its "1 POR CIENTO
|
|
// SOBRE SUMA ASEGURADA".
|
|
if (isEspecHeading(lines, i)) break;
|
|
const inline = line.match(/^Deducibles?\s*:\s*(\S.*)$/i);
|
|
if (inline) return inline[1].trim();
|
|
if (!/^Deducibles?\s*:?\s*$/i.test(line)) continue;
|
|
for (let j = i + 1; j < to; j++) {
|
|
const value = lines[j].trim();
|
|
if (!value) continue;
|
|
// Too long to be a deductible: this is the page-level `DEDUCIBLES:`
|
|
// heading and the paragraph explaining the inflation clause, not this
|
|
// coverage's value. Keep looking rather than concluding "none".
|
|
if (value.length <= 60) return value;
|
|
break;
|
|
}
|
|
}
|
|
return espectPercentDeductibleAbove(lines, from);
|
|
}
|
|
|
|
/**
|
|
* Some coverages print their deductible and coinsurance as a sentence ABOVE
|
|
* the limit instead of under a `Deducible:` label below it — "Bienes a la
|
|
* Intemperie" reads
|
|
*
|
|
* 5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO
|
|
* DEL ASEGURADO DE TODA Y CADA PÉRDIDA.
|
|
*
|
|
* wrapped across two lines. Left unread it looks like a coverage with no
|
|
* deductible at all, which is the one wrong answer worth avoiding here.
|
|
*
|
|
* Two bounds keep it from wandering into the body prose, and both are
|
|
* load-bearing — without them the fire section's "…o hasta el 10% de la suma
|
|
* asegurada de la sección de Edificio, lo que resulte menor" (a sublimit rule
|
|
* for the building, several paragraphs up) becomes the deductible of
|
|
* CONTENIDOS:
|
|
*
|
|
* - the walk stops at this coverage's own heading, so it can never read text
|
|
* belonging to the coverage above it, and
|
|
* - the line must OPEN with the percentage, which a prose sentence that
|
|
* merely contains one does not.
|
|
*/
|
|
function espectPercentDeductibleAbove(lines: string[], limitAt: number): string | null {
|
|
for (let i = limitAt - 1, walked = 0; i >= 0 && walked < 6; i--, walked++) {
|
|
const line = lines[i].trim();
|
|
if (!line) continue;
|
|
// The heading of this coverage: stop, everything above belongs to another.
|
|
if (line.length <= 60 && !ESPEC_STRUCTURAL.test(line) && i > 0 && !lines[i - 1].trim()) {
|
|
return null;
|
|
}
|
|
if (!/^\d+\s*(POR\s*CIENTO|PORCIENTO|%)/i.test(line)) continue;
|
|
// Re-join the wrapped continuation lines beneath it.
|
|
const parts = [line];
|
|
for (let j = i + 1; j < limitAt && lines[j].trim(); j++) parts.push(lines[j].trim());
|
|
return parts.join(" ").replace(/\s+/g, " ");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Coverages the especificación states inline inside the fire section instead
|
|
* of under a limit label:
|
|
*
|
|
* "2. Terremoto o erupción volcánica: Sección Edificio EXCLUIDO, Sección Contenidos EXCLUIDO"
|
|
* "3. Fenómenos hidrometeorológicos: Sección Edificio $200,000.00 USD, Sección Contenidos $20,000.00 USD"
|
|
*
|
|
* An exclusion is recorded in the risk label rather than as a zero amount:
|
|
* a coverage that is excluded and a coverage insured for $0 are the same
|
|
* number and very different facts, and `ParsedCoverage` has no field for the
|
|
* distinction.
|
|
*/
|
|
function espectInlineSectionRisks(
|
|
lines: string[],
|
|
currencyVotes: Record<string, number>,
|
|
notes: string[],
|
|
): ParsedCoverage[] {
|
|
const out: ParsedCoverage[] = [];
|
|
const cat = espectCatastrophicTerms(lines, notes);
|
|
|
|
for (const raw of lines) {
|
|
const m = raw.trim().match(/^\d+\.\s*([^:]{3,60}?)\s*:\s*(.*Secci[óo]n.+)$/i);
|
|
if (!m) continue;
|
|
const risk = m[1].trim();
|
|
for (const part of m[2].split(/,\s*(?=Secci[óo]n)/i)) {
|
|
const section = part.match(/Secci[óo]n\s+(\S+)/i)?.[1] ?? null;
|
|
const label = section ? `${risk} — Sección ${section}` : risk;
|
|
// Deductible/coinsurance for these two live in their own block further
|
|
// down the document, keyed by section rather than printed here.
|
|
const terms = /hidrometeorol/i.test(risk) && section ? cat[section.toLowerCase()] : undefined;
|
|
|
|
if (/EXCLUIDO|NO\s+CUBIERT/i.test(part)) {
|
|
out.push({
|
|
risk: `${label}: EXCLUIDO`,
|
|
insuredAmount: null,
|
|
deductible: null,
|
|
lossParticipation: null,
|
|
});
|
|
continue;
|
|
}
|
|
const amount = part.match(ESPEC_AMOUNT);
|
|
if (!amount) continue;
|
|
const currency = espectCurrency(amount[2]);
|
|
if (currency) currencyVotes[currency] = (currencyVotes[currency] ?? 0) + 1;
|
|
out.push({
|
|
risk: label,
|
|
insuredAmount: money(amount[1]),
|
|
deductible: terms?.deductible ?? null,
|
|
lossParticipation: terms?.coinsurance ?? null,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The `Fenómenos hidrometeorológicos` block, which states the deductible and
|
|
* the coinsurance per section:
|
|
*
|
|
* Fenómenos hidrometeorológicos
|
|
* Zona: A2
|
|
* Deducible
|
|
* Edificio: 1 POR CIENTO SOBRE SUMA ASEGURADA
|
|
* Coaseguro:
|
|
* Zona 1: (INTERIOR) Participación a cargo del asegurado del 10% …
|
|
* Zona 2: Participación a cargo del asegurado del 10% …
|
|
*
|
|
* The coinsurance is quoted per catastrophe zone, and the policy's own zone
|
|
* ("A2") is not one of the labels used in that list — so it is only read when
|
|
* every listed zone quotes the SAME percentage, which makes the mapping moot.
|
|
* When they differ, the reviewer is told to read the zone table by hand
|
|
* rather than being handed a percentage picked by guesswork.
|
|
*/
|
|
function espectCatastrophicTerms(
|
|
lines: string[],
|
|
notes: string[],
|
|
): Record<string, { deductible: string | null; coinsurance: string | null }> {
|
|
const out: Record<string, { deductible: string | null; coinsurance: string | null }> = {};
|
|
|
|
const start = lines.findIndex((l) => /^\s*Fen[óo]menos\s+hidrometeorol[óo]gicos\s*$/i.test(l));
|
|
if (start < 0) return out;
|
|
let end = lines.length;
|
|
for (let i = start + 1; i < lines.length; i++) {
|
|
if (/^\s*(I+\.-\s*)?SECCI[ÓO]N\b/i.test(lines[i])) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
const block = lines.slice(start, end);
|
|
|
|
const zone = firstMatch(block.join("\n"), [/^\s*Zona\s*:\s*(\S+)\s*$/im]);
|
|
if (zone) notes.push(`zona catastrófica declarada: ${zone}`);
|
|
|
|
const percents = [
|
|
...new Set(
|
|
allMatches(
|
|
block.join("\n"),
|
|
/Participaci[óo]n\s+a\s+cargo\s+del\s+asegurado\s+del\s+(\d+%)/i,
|
|
),
|
|
),
|
|
];
|
|
let coinsurance: string | null = null;
|
|
if (percents.length === 1) {
|
|
coinsurance = percents[0];
|
|
} else if (percents.length > 1) {
|
|
notes.push(
|
|
`el coaseguro de fenómenos hidrometeorológicos varía por zona (${percents.join(", ")}); revisarlo a mano`,
|
|
);
|
|
}
|
|
|
|
for (const line of block) {
|
|
const m = line.trim().match(/^(Edificio|Contenidos)\s*:\s*(\S.+)$/i);
|
|
if (!m) continue;
|
|
out[m[1].toLowerCase()] = { deductible: m[2].trim(), coinsurance };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** `USD` / `M.N.` / `DLLS` as printed next to an amount → an ISO code. */
|
|
function espectCurrency(raw: string | null | undefined): string | null {
|
|
if (!raw) return null;
|
|
const s = raw.replace(/[.\s]/g, "").toUpperCase();
|
|
if (s === "USD" || s.startsWith("DL") || s.startsWith("DLL")) return "USD";
|
|
if (s === "MN" || s === "MXN") return "MXN";
|
|
return null;
|
|
} |