feat(polizas): OCR capture for insurance policy PDFs
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m43s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m0s

Mirrors the utility statement intake on the insurance side: a policy_ocr
batch/document pair of tables, a GMX parser, a matcher keyed on
Policy.policyNumber, and a "Captura" screen under /polizas that proposes
policy -> customer for staff to confirm.

Lifts the OCR seam out of StatementsModule into its own OcrModule so
PolicyOcrModule can inject OCR_PROVIDER without taking on the rest of
the statement pipeline; StatementsModule now imports it and binds
nothing itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 14:07:29 -07:00
co-authored by Claude Opus 5
parent 5bce0e4c94
commit 5e9cb12fba
22 changed files with 3203 additions and 136 deletions
+2
View File
@@ -10,6 +10,7 @@ import { PoliciesModule } from "./policies/policies.module";
import { PropertiesModule } from "./properties/properties.module"; import { PropertiesModule } from "./properties/properties.module";
import { BillingModule } from "./billing/billing.module"; import { BillingModule } from "./billing/billing.module";
import { StatementsModule } from "./statements/statements.module"; import { StatementsModule } from "./statements/statements.module";
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
import { BankModule } from "./bank/bank.module"; import { BankModule } from "./bank/bank.module";
import { OpsModule } from "./ops/ops.module"; import { OpsModule } from "./ops/ops.module";
import { ReportsModule } from "./reports/reports.module"; import { ReportsModule } from "./reports/reports.module";
@@ -28,6 +29,7 @@ import { AppController } from "./app.controller";
PropertiesModule, PropertiesModule,
BillingModule, BillingModule,
StatementsModule, StatementsModule,
PolicyOcrModule,
BankModule, BankModule,
OpsModule, OpsModule,
ReportsModule, ReportsModule,
+6
View File
@@ -24,6 +24,8 @@ export type Ability =
| "policy:create" | "policy:create"
| "policy:update" | "policy:update"
| "policy:delete" | "policy:delete"
| "policy:ingest"
| "policy:ocr-review"
| "property:create" | "property:create"
| "property:update" | "property:update"
| "property:delete" | "property:delete"
@@ -46,6 +48,10 @@ export const ABILITY_MIN: Record<Ability, Role> = {
"policy:create": "STAFF", "policy:create": "STAFF",
"policy:update": "STAFF", "policy:update": "STAFF",
"policy:delete": "MANAGER", "policy:delete": "MANAGER",
// Insurance OCR intake is the same trust tier as statement OCR: STAFF can
// upload + confirm, nothing reaches the books unconfirmed.
"policy:ingest": "STAFF",
"policy:ocr-review": "STAFF",
"property:create": "STAFF", "property:create": "STAFF",
"property:update": "STAFF", "property:update": "STAFF",
"property:delete": "MANAGER", "property:delete": "MANAGER",
+18
View File
@@ -0,0 +1,18 @@
import { Module } from "@nestjs/common";
import { OCR_PROVIDER } from "../statements/ocr/ocr.provider";
import { TesseractOcrProvider } from "../statements/ocr/tesseract.provider";
/**
* Lifts the OCR seam out of StatementsModule so other modules (today:
* PolicyOcrModule) can inject OCR_PROVIDER without taking on the rest of
* the statement intake. StatementsModule itself imports this and gets the
* provider the same way.
*
* The concrete engine is still bound here — Tesseract today, a managed
* extraction API later is a one-line change in this file.
*/
@Module({
providers: [{ provide: OCR_PROVIDER, useClass: TesseractOcrProvider }],
exports: [OCR_PROVIDER],
})
export class OcrModule {}
@@ -0,0 +1,147 @@
import type { OcrPage } from "../../statements/ocr/ocr.provider";
import {
detectPolicyProvider,
parsePolicy,
type ParsedCoverage,
} from "./policy-parser";
/**
* Verbatim excerpts of what the GMX portal's translation PDF actually
* rendered through pdftotext — same convention as the statement parser
* tests, where invented-clean input would test nothing because clean input
* is not the failure mode.
*/
function page(text: string): OcrPage {
return { text, words: [], confidence: 0.95 };
}
describe("detectPolicyProvider", () => {
it("claims GMX from the brand wordmark on the letterhead", () => {
expect(
detectPolicyProvider(
"Grupo Mexicano de Seguros, S.A. de C.V.\nTecoyotitla 412, Edificio GMX",
),
).toBe("GMX");
});
it("claims GMX from the 'gmx.com.mx' footer URL", () => {
expect(detectPolicyProvider("JUNTOS EL RIESGO ES MENOR\nwww.gmx.com.mx")).toBe("GMX");
});
});
describe("parsePolicy / GMX", () => {
// Verbatim text extracted from ~/Downloads/HC_Folio_000767_Traduccion.pdf via
// `pdftotext -layout`. Two pages joined by "\n\n".
const GMX_FULL = page(
"Multiple Policy\nHome\n" +
"Policy 007-037-07005947-0000-02 in accordance with the enclosed clauses, to insurance:\n" +
"Insured JON ASHLEY STRABALA\n" +
"Additional insured VIVIAN\n" +
"Legal address BONAMPACK No. EXT26 No.INT 0 COL. Punta Bandera, Tijuana, Baja California, C.P. 22550\n" +
"ZIP 22550 Income Tax No. XEXX-010101-000\n" +
"Broker (1176) Jorge Humberto Cuadros\n" +
"Term 12 months\n" +
"From 19/07/2026\n" +
"To 19/07/2027 at twelve hours (noon) Mexico City time.\n" +
"Currency DOLARES Premium payment CONTADO\n" +
"Free translation from the Spanish Insurance contract. The English text is just copy given by courtesy. In case of a dispute, the Spanish will prevail over the English version.\n" +
"Agreed clauses:\n" +
"•The insured and GMX Hereby declared...\n" +
"From the above, the present contract shall not be considered under the condition mentioned within article 36-B from the Insurance Companies General Law. Therefore it shall not be required its registration before the Comision National de Seguros y Fianzas.\n" +
"July 23, 2026\n" +
"Authority sign.\n" +
"Grupo Mexicano de Seguros, S.A. de C.V.\n" +
"Tecoyotitla 412, Edificio GMX\n" +
"JUNTOS EL RIESGO ES MENOR\n" +
"www.gmx.com.mx\n\n" +
"Risk Insured Amount Deductible Loss Participation\n" +
"Building $350,000.00 Not applies Not applies\n" +
"Contents $60,000.00 Not applies Not applies\n" +
"ADDITIONAL RISK\n" +
"Risk Insured Amount Deductible Loss Participation\n" +
"Debris removal Building $35,000.00 Not applies Not applies\n" +
"Debris removal Contents $6,000.00 Not applies Not applies\n" +
"Outdoors Constructions $10,000.00 5% 10%\n" +
"Coverage Extention Covered Not applies Not applies\n" +
"All Risk Covered Not applies Not applies\n" +
"Earthquake and/or volcanic eruption Covered 2% of the sum insured for each damage structure 20%\n" +
"Extra Expenses $41,000.00 Not applies Not applies\n" +
"Robbery with violence $10,000.00 Not applies Not applies\n" +
"Jewerly $3,900.00 Not applies Not applies\n" +
"Electronic Equipment $10,000.00 Not applies Not applies\n" +
"Glasses $10,000.00 Not applies Not applies\n" +
"Tenant $200,000.00 Not applies Not applies\n" +
"Family $200,000.00 Not applies Not applies\n" +
"Family $200,000.00 Not applies Not applies\n" +
"Domestic workers $7,010.00 Not applies Not applies\n" +
"VALUES ADDED, HOME GMX",
);
it("extracts the policy number, insured name, broker, dates, and currency", () => {
const p = parsePolicy(GMX_FULL);
expect(p.provider).toBe("GMX");
expect(p.policyNumber).toBe("007-037-07005947-0000-02");
expect(p.insuredName).toBe("JON ASHLEY STRABALA");
expect(p.additionalInsured).toBe("VIVIAN");
expect(p.agentName).toBe("Jorge Humberto Cuadros");
expect(p.policyFrom?.toISOString().slice(0, 10)).toBe("2026-07-19");
expect(p.policyTo?.toISOString().slice(0, 10)).toBe("2027-07-19");
expect(p.policyDate?.toISOString().slice(0, 10)).toBe("2026-07-23");
expect(p.currency).toBe("USD");
expect(p.zip).toBe("22550");
expect(p.legalAddress).toContain("BONAMPACK");
expect(p.premiumPayment).toBe("CONTADO");
});
it("extracts every coverage row off the second page table", () => {
const p = parsePolicy(GMX_FULL);
const byName = Object.fromEntries(p.coverages.map((c) => [c.risk, c]));
expect(byName.Building?.insuredAmount).toBe(350000);
expect(byName.Contents?.insuredAmount).toBe(60000);
expect(byName["Debris removal Building"]?.insuredAmount).toBe(35000);
expect(byName["Outdoors Constructions"]?.insuredAmount).toBe(10000);
expect(byName["Outdoors Constructions"]?.deductible).toBe("5%");
expect(byName["Outdoors Constructions"]?.lossParticipation).toBe("10%");
// Free-text coverage cells kept verbatim (the policy form surfaces them
// as observations, not as numbers).
expect(byName["Earthquake and/or volcanic eruption"]?.insuredAmount).toBeNull();
expect(byName["Earthquake and/or volcanic eruption"]?.deductible).toContain("2%");
expect(byName["Earthquake and/or volcanic eruption"]?.lossParticipation).toBe("20%");
expect(byName["All Risk"]?.insuredAmount).toBeNull();
expect(p.coverages.length).toBeGreaterThan(10);
});
it("leaves premium fields null on the certificate page and notes it", () => {
const p = parsePolicy(GMX_FULL);
expect(p.netPremium).toBeNull();
expect(p.total).toBeNull();
expect(p.policyFee).toBeNull();
expect(p.notes.join(" ")).toMatch(/prima/i);
});
it("still parses when the broker parens are missing", () => {
const p = parsePolicy(
page(
"Insured JON ASHLEY STRABALA\nBroker Jorge Humberto Cuadros\n" +
"From 19/07/2026\nTo 19/07/2027\nCurrency DOLARES\n" +
"Grupo Mexicano de Seguros",
),
);
expect(p.agentName).toBe("Jorge Humberto Cuadros");
});
it("rejects a page that carries no GMX signal at all", () => {
const p = parsePolicy(page("Random unrelated document with no policy data."));
expect(p.provider).toBe("");
expect(p.notes.join(" ")).toContain("no se reconoció el proveedor");
});
it("captures the deductible / loss-participation columns verbatim as strings", () => {
const p = parsePolicy(GMX_FULL);
const eq = p.coverages.find((c) => c.risk === "Earthquake and/or volcanic eruption");
expect(eq).toBeDefined();
const eqTyped = eq as ParsedCoverage;
expect(eqTyped.deductible).toContain("sum insured");
expect(eqTyped.lossParticipation).toBe("20%");
});
});
@@ -0,0 +1,422 @@
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;
}
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/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 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 parseGmx(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, [
/\bPolicy\s+([0-9OIlSBD]{3,4}[-\s][0-9OIlSBD]{3}[-\s][0-9OIlSBD]{8}[-\s][0-9OIlSBD]{4}[-\s][0-9OIlSBD]{2})/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);
const 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/);
if (m) 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: policyNumber ? policyNumber.replace(/\s+/g, "") : null,
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;
}
@@ -0,0 +1,101 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import type { ParsedPolicy } from "./parsers/policy-parser";
export interface MatchResult {
policyId: string | null;
customerId: string | null;
/** Why it landed here — shown in the review queue verbatim. */
note: string;
/** True only for an unambiguous hit on `Policy.policyNumber`. */
confident: boolean;
/**
* Every policy that carries the parsed number, with its customer. >1 means
* the policy number is shared across customers and a human must pick.
*/
candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[];
}
/**
* Resolves a parsed policy page to an existing Policy (and its customer) the
* office already holds.
*
* **Match on `Policy.policyNumber` alone, never on the printed insured name.**
* The certificate's "Insured" line is the account's registrant, which drifts
* from the current owner — the same problem the statement matcher cites for
* utility bills ("ARNAIZ ROSAS ELSA AURORA" on a CESPT receipt for a
* customer this office holds as "CATT, RANDY"). Names are surfaced for the
* reviewer to sanity-check and never feed matching.
*
* A policy number that matches zero rows means the policy is new: the
* review screen then offers a customer picker and the confirm step creates
* the row. Multiple hits are surfaced rather than auto-picked — duplicate
* policy numbers across customers do occur (same group policy bound by two
* related parties), and picking one arbitrarily would silently book the
* wrong coverage.
*/
@Injectable()
export class PolicyMatcherService {
constructor(private readonly prisma: PrismaService) {}
async match(parsed: ParsedPolicy): Promise<MatchResult> {
if (!parsed.policyNumber) {
return this.unmatched("no se pudo leer el número de póliza");
}
const rows = await this.prisma.policy.findMany({
where: { policyNumber: parsed.policyNumber },
select: {
id: true,
policyNumber: true,
customerId: true,
customer: { select: { name: true } },
},
});
const candidates = rows.map((r) => ({
policyId: r.id,
customerId: r.customerId,
customerName: r.customer.name,
policyNumber: r.policyNumber,
}));
if (rows.length === 0) {
return {
policyId: null,
customerId: null,
note: `no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
confident: false,
candidates: [],
};
}
if (rows.length > 1) {
return {
policyId: null,
customerId: null,
note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
confident: false,
candidates,
};
}
return {
policyId: candidates[0].policyId,
customerId: candidates[0].customerId,
note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
confident: true,
candidates,
};
}
private unmatched(note: string): MatchResult {
return {
policyId: null,
customerId: null,
note,
confident: false,
candidates: [],
};
}
}
@@ -0,0 +1,161 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
Req,
Res,
StreamableFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FilesInterceptor } from "@nestjs/platform-express";
import type { Request, Response } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import type { UploadedFileLike } from "../storage/upload-file";
import { PolicyOcrService } from "./policy-ocr.service";
import {
ConfirmPolicyBatchDto,
CreatePolicyOcrBatchDto,
ReviewPolicyDocumentDto,
} from "./policy-ocr.dto";
/**
* Insurance OCR intake (policy_ocr_intake).
*
* Mirrors StatementsController shape: one batch = one upload session of
* policy PDFs from a provider portal (GMX today), one document per page.
* Confirming a batch delegates nothing to a separate billing path —
* everything goes through `Policy` (and optionally a Transaction for the
* premium), the same tables the manual `PolicyForm` writes.
*/
@Controller("policy-ocr")
@UseGuards(AuthenticatedGuard, AbilityGuard)
export class PolicyOcrController {
constructor(
private readonly policyOcr: PolicyOcrService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string } | undefined)?.id ?? "";
}
@Get("status")
async status() {
return {
ocrAvailable: await this.policyOcr.ocrAvailable(),
storageAvailable: this.policyOcr.storageAvailable(),
};
}
@Get("batches")
listBatches(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.policyOcr.listBatches(
Math.max(1, Number(page) || 1),
Math.min(100, Math.max(1, Number(pageSize) || 25)),
);
}
@Get("batches/:id")
getBatch(@Param("id") id: string) {
return this.policyOcr.getBatch(id);
}
@Get("batches/:id/documents")
listDocuments(@Param("id") id: string) {
return this.policyOcr.listDocuments(id);
}
/**
* The source PDF for a parsed policy document. One PDF = one parsed policy,
* so this returns the entire upload (typically multi-page for insurance
* certificates). The review screen embeds it in an iframe.
*/
@Get("documents/:id/page")
async pageImage(
@Param("id") id: string,
@Res({ passthrough: true }) res: Response,
) {
const { stream, contentType, contentLength } = await this.policyOcr.pageImage(id);
res.set({
// The doc row stores the source PDF, not a rendered page image.
"Content-Type": contentType ?? "application/pdf",
...(contentLength ? { "Content-Length": String(contentLength) } : {}),
});
return new StreamableFile(stream);
}
// --- writes ---------------------------------------------------------------
@Post("batches")
@RequireAbility("policy:ingest")
@UseInterceptors(
FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }),
)
async createBatch(
@UploadedFiles() files: UploadedFileLike[] | undefined,
@Body() _dto: CreatePolicyOcrBatchDto,
@Query("label") label: string | undefined,
@Req() req: Request,
) {
const batch = await this.policyOcr.createBatch(
files ?? [],
this.actingId(req),
label ?? _dto.label,
);
void this.audit.log(this.actingId(req), "policyOcr.batch.create", {
batchId: batch.id,
fileCount: batch.fileCount,
});
return batch;
}
@Patch("documents/:id")
@RequireAbility("policy:ocr-review")
async review(
@Param("id") id: string,
@Body() dto: ReviewPolicyDocumentDto,
@Req() req: Request,
) {
const doc = await this.policyOcr.review(id, dto, this.actingId(req));
void this.audit.log(this.actingId(req), "policyOcr.document.review", {
documentId: id,
status: doc.status,
});
return doc;
}
@Post("documents/:id/reject")
@RequireAbility("policy:ocr-review")
async reject(@Param("id") id: string, @Req() req: Request) {
const doc = await this.policyOcr.reject(id, this.actingId(req));
void this.audit.log(this.actingId(req), "policyOcr.document.reject", {
documentId: id,
});
return doc;
}
@Post("batches/:id/confirm")
@RequireAbility("policy:ocr-review")
async confirm(
@Param("id") id: string,
@Body() dto: ConfirmPolicyBatchDto,
@Req() req: Request,
) {
const result = await this.policyOcr.confirmBatch(id, dto, this.actingId(req));
void this.audit.log(this.actingId(req), "policyOcr.batch.confirm", {
batchId: id,
applied: result.applied,
postedTransactions: result.postedTransactions,
});
return result;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { Type } from "class-transformer";
import {
IsArray,
IsDateString,
IsEnum,
IsNumber,
IsObject,
IsOptional,
IsString,
MinLength,
ValidateNested,
} from "class-validator";
/** One document's confirmed-after-review state. The service reads these
* fields and writes them onto either a matched Policy or a freshly created
* one. Anything null here is not written. */
export class ConfirmPolicyDocumentDto {
@IsString() documentId!: string;
/** Required when creating a new Policy; ignored if `policyId` is set. */
@IsOptional() @IsString() customerId?: string;
/** Set when the document matched an existing Policy. */
@IsOptional() @IsString() policyId?: string;
@IsOptional() @IsString() policyNumber?: string;
@IsOptional() @IsString() insuredName?: string;
@IsOptional() @IsString() additionalInsured?: string;
@IsOptional() @IsString() agentName?: string;
@IsOptional() @IsString() legalAddress?: string;
@IsOptional() @IsString() zip?: string;
@IsOptional() @IsDateString() policyFrom?: string;
@IsOptional() @IsDateString() policyTo?: string;
@IsOptional() @IsDateString() policyDate?: string;
@IsOptional() @IsEnum(["MXN", "USD", "EUR"]) currency?: "MXN" | "USD" | "EUR";
@IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() total?: number;
@IsOptional() @IsString() premiumPayment?: string;
/** Coverages parsed off the PDF, passed through verbatim to Policy.coveragesJson. */
@IsOptional() @IsObject() coveragesJson?: unknown;
/** When true, write a Transaction(domain=INSURANCE, amount=-netPremium)
* in addition to creating/updating the Policy. Skipped if netPremium is
* null or zero. */
@IsOptional() postPremium?: boolean;
}
export class ConfirmPolicyBatchDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ConfirmPolicyDocumentDto)
documents!: ConfirmPolicyDocumentDto[];
}
/** Staff correction of one document's extracted fields or its match. */
export class ReviewPolicyDocumentDto {
@IsOptional() @IsString() policyNumber?: string;
@IsOptional() @IsString() insuredName?: string;
@IsOptional() @IsString() additionalInsured?: string;
@IsOptional() @IsString() agentName?: string;
@IsOptional() @IsString() legalAddress?: string;
@IsOptional() @IsString() zip?: string;
@IsOptional() @IsDateString() policyFrom?: string;
@IsOptional() @IsDateString() policyTo?: string;
@IsOptional() @IsDateString() policyDate?: string;
@IsOptional() @IsString() currency?: string;
@IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() total?: number;
@IsOptional() @IsString() premiumPayment?: string;
@IsOptional() @IsObject() coveragesJson?: unknown;
/** Set by the reviewer when the document matched an existing Policy. */
@IsOptional() @IsString() matchedPolicyId?: string;
/** Set by the reviewer when creating a new Policy. */
@IsOptional() @IsString() matchedCustomerId?: string;
/** Force-confirm a doc even when the matcher left it ambiguous. */
@IsOptional() forceConfirm?: boolean;
}
export class CreatePolicyOcrBatchDto {
@IsOptional() @IsString() @MinLength(1) label?: string;
}
@@ -0,0 +1,18 @@
import { Module } from "@nestjs/common";
import { OcrModule } from "../ocr/ocr.module";
import { PolicyOcrController } from "./policy-ocr.controller";
import { PolicyOcrService } from "./policy-ocr.service";
import { PolicyMatcherService } from "./policy-matcher.service";
/**
* Reuses the OCR seam from OcrModule unchanged: the Tesseract provider is
* bound there and `OcrProvider` is the only thing the parsers touch. This
* module registers its own controller + service + matcher; nothing about
* utility ingestion needs to know about it.
*/
@Module({
imports: [OcrModule],
controllers: [PolicyOcrController],
providers: [PolicyOcrService, PolicyMatcherService],
})
export class PolicyOcrModule {}
@@ -0,0 +1,720 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { Currency, Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { StorageService } from "../storage/storage.service";
import type { UploadedFileLike } from "../storage/upload-file";
import { OCR_PROVIDER, type OcrPage, type OcrProvider } from "../statements/ocr/ocr.provider";
import { parsePolicy } from "./parsers/policy-parser";
import { PolicyMatcherService } from "./policy-matcher.service";
import type {
ConfirmPolicyBatchDto,
ConfirmPolicyDocumentDto,
ReviewPolicyDocumentDto,
} from "./policy-ocr.dto";
/**
* Insurance OCR intake — mirrors the statement pipeline at
* `apps/api/src/statements/statements.service.ts`. Reuses the OCR seam and
* Tesseract binding unchanged; the parsers and matcher are policy-specific.
*
* Why a parallel pipeline rather than a column on StatementDocument: the
* matcher keys on `Policy.policyNumber`, the confirm step writes to a
* different table (`Policy`, not `Transaction`), and the review UI shows
* different fields. Sharing one queue would either bloat the row with null
* columns or force the review screen to branch on a discriminator — both
* worse than a thin second table.
*/
@Injectable()
export class PolicyOcrService {
private readonly logger = new Logger(PolicyOcrService.name);
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
private readonly matcher: PolicyMatcherService,
@Inject(OCR_PROVIDER) private readonly ocr: OcrProvider,
) {}
ocrAvailable(): Promise<boolean> {
return this.ocr.available();
}
storageAvailable(): boolean {
return this.storage.available;
}
// --- ingest ---------------------------------------------------------------
async createBatch(
files: UploadedFileLike[],
uploadedById: string,
label?: string,
) {
if (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
if (!(await this.ocr.available())) {
throw new BadRequestException(
"El servidor no tiene OCR instalado; no se pueden leer PDFs de pólizas.",
);
}
if (!this.storage.available) {
throw new BadRequestException(
"El almacenamiento de documentos no está configurado; no se pueden " +
"guardar los PDFs escaneados.",
);
}
const batch = await this.prisma.policyOcrBatch.create({
data: { provider: "GMX", uploadedById, label, fileCount: files.length },
});
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
void this.process(batch.id, copies).catch(async (err) => {
this.logger.error(`Policy OCR batch ${batch.id} failed: ${(err as Error).message}`);
await this.prisma.policyOcrBatch.update({
where: { id: batch.id },
data: { status: "FAILED", error: (err as Error).message },
});
});
return batch;
}
/**
* Render → text → parse → match, **one PolicyOcrDocument row per uploaded
* file**. The GMX certificate is a 2-page PDF where page 1 carries the
* contract header and page 2 carries the per-coverage table — both pages
* describe the SAME policy, so the parser concatenates them and the
* matcher runs once. `pageNumber` on the row is repurposed as the file
* ordinal within the batch (1, 2, 3…) — the unique constraint
* `(batchId, pageNumber)` still holds and lets a single batch carry many
* policies.
*
* The doc's `storageKey` is the SOURCE PDF (`policy-ocr/{batchId}/source-N.pdf`)
* rather than a rendered page image, so the review screen can embed the
* exact artifact the office received. The rendered page PNGs are still
* stored under `policy-ocr/{batchId}/page-M.png` for any future re-OCR or
* image-based audit, but they aren't used as `storageKey` for the document.
*/
private async process(
batchId: string,
files: { buffer: Buffer; name?: string }[],
) {
await this.prisma.policyOcrBatch.update({
where: { id: batchId },
data: { status: "PROCESSING" },
});
let fileOrdinal = 0;
let globalPageOrdinal = 0;
for (const file of files) {
fileOrdinal += 1;
const sourceKey = `policy-ocr/${batchId}/source-${fileOrdinal}.pdf`;
await this.storage.put(sourceKey, file.buffer, "application/pdf");
const pages = await this.ocr.renderPages(file.buffer);
const textLayer = await this.ocr.textPages(file.buffer).catch(() => []);
// One OcrPage per rendered page: text-layer wins when present (cheap,
// exact), OCR the rendered image when it isn't. Same precedence rule
// as the statement OCR pipeline.
const perPageOcr: OcrPage[] = [];
for (const [index, image] of pages.entries()) {
globalPageOrdinal += 1;
const pageStorageKey = `policy-ocr/${batchId}/page-${globalPageOrdinal}.png`;
await this.storage.put(pageStorageKey, image, "image/png");
const embedded = textLayer[index] ?? null;
const pageOcr = embedded ?? (await this.ocr.recognize(image));
perPageOcr.push(pageOcr);
}
// Concatenate every page's text with a blank line between pages so the
// parser's anchored regexes (^From$, ^Currency\s+...) still work
// across page boundaries — pdftotext -bbox-layout produces newline-
// separated text per page already, the `\n\n` just preserves a clear
// boundary in ocrRawText for debugging.
const mergedText = perPageOcr.map((p) => p.text).join("\n\n");
const avgConfidence =
perPageOcr.length === 0
? 0
: perPageOcr.reduce((s, p) => s + p.confidence, 0) / perPageOcr.length;
const synthetic: OcrPage = {
text: mergedText,
words: [],
confidence: avgConfidence,
};
try {
const parsed = parsePolicy(synthetic);
if (parsed.provider === "") {
throw new Error("no se reconoció el proveedor");
}
const match = await this.matcher.match(parsed);
const notes = [...parsed.notes, match.note].filter(Boolean);
// Confident when exactly one Policy carries the printed number —
// the only unambiguous hit we trust. A new policy (no match) still
// needs a customer pick, so it stays in review.
const trusted = match.confident && parsed.policyNumber != null;
await this.prisma.policyOcrDocument.create({
data: {
batchId,
pageNumber: fileOrdinal,
storageKey: sourceKey,
status: trusted ? "MATCHED" : "NEEDS_REVIEW",
ocrRawText: mergedText,
ocrConfidence: new Prisma.Decimal(avgConfidence.toFixed(3)),
provider: parsed.provider,
extractedPolicyNumber: parsed.policyNumber,
extractedInsuredName: parsed.insuredName,
extractedAdditionalInsured: parsed.additionalInsured,
extractedAgentName: parsed.agentName,
extractedLegalAddress: parsed.legalAddress,
extractedZip: parsed.zip,
extractedPolicyFrom: parsed.policyFrom,
extractedPolicyTo: parsed.policyTo,
extractedPolicyDate: parsed.policyDate,
extractedCurrency: parsed.currency,
extractedNetPremium:
parsed.netPremium != null ? new Prisma.Decimal(parsed.netPremium) : null,
extractedPolicyFee:
parsed.policyFee != null ? new Prisma.Decimal(parsed.policyFee) : null,
extractedBrokerFee:
parsed.brokerFee != null ? new Prisma.Decimal(parsed.brokerFee) : null,
extractedTotal:
parsed.total != null ? new Prisma.Decimal(parsed.total) : null,
extractedCoveragesJson: parsed.coverages.length
? (parsed.coverages as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
extractedPremiumPayment: parsed.premiumPayment,
matchedPolicyId: match.policyId,
matchedCustomerId: match.customerId,
matchCandidates: match.candidates.length
? (match.candidates as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
matchNote: notes.join("; ").slice(0, 190),
},
});
} catch (err) {
// The file as a whole failed to parse (no provider, parse exception).
// One OCR_FAILED row per file is the right granularity — the page
// images are still on disk for a re-run after a parser fix.
await this.prisma.policyOcrDocument.create({
data: {
batchId,
pageNumber: fileOrdinal,
storageKey: sourceKey,
status: "OCR_FAILED",
matchNote: (err as Error).message.slice(0, 190),
},
});
}
}
await this.prisma.policyOcrBatch.update({
where: { id: batchId },
data: { status: "READY_FOR_REVIEW" },
});
}
// --- reads ----------------------------------------------------------------
async listBatches(page: number, pageSize: number) {
const [total, items] = await this.prisma.$transaction([
this.prisma.policyOcrBatch.count(),
this.prisma.policyOcrBatch.findMany({
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
uploadedBy: { select: { name: true } },
_count: { select: { documents: true } },
},
}),
]);
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
}
async getBatch(id: string) {
const batch = await this.prisma.policyOcrBatch.findUnique({
where: { id },
include: { uploadedBy: { select: { name: true } } },
});
if (!batch) throw new NotFoundException("Lote no encontrado.");
const counts = await this.prisma.policyOcrDocument.groupBy({
by: ["status"],
where: { batchId: id },
_count: { _all: true },
});
return {
...batch,
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
};
}
async listDocuments(batchId: string) {
return this.prisma.policyOcrDocument.findMany({
where: { batchId },
orderBy: { pageNumber: "asc" },
include: {
matchedCustomer: { select: { id: true, name: true } },
matchedPolicy: {
select: {
id: true,
policyNumber: true,
customerId: true,
customer: { select: { name: true } },
},
},
},
});
}
/**
* The source PDF for the document, so the review screen can show the
* exact artifact the office uploaded (the browser's PDF viewer handles
* scrolling, zoom, and selection natively). The rendered page PNGs
* remain on disk under `policy-ocr/{batchId}/page-N.png` for any
* future re-OCR, but the doc row points here at the source.
*/
async pageImage(documentId: string) {
const doc = await this.prisma.policyOcrDocument.findUnique({
where: { id: documentId },
select: { storageKey: true },
});
if (!doc) throw new NotFoundException("Documento no encontrado.");
return this.storage.getStream(doc.storageKey);
}
// --- review ---------------------------------------------------------------
async review(id: string, dto: ReviewPolicyDocumentDto, reviewedById: string) {
const doc = await this.prisma.policyOcrDocument.findUnique({ where: { id } });
if (!doc) throw new NotFoundException("Documento no encontrado.");
if (doc.status === "POSTED") {
throw new BadRequestException("Este documento ya fue aplicado.");
}
// Trusting a customer-supplied pair (policyId, customerId) without
// cross-check is how a document lands on the wrong customer's ledger;
// pin them here from the DB.
let matchedPolicyId = dto.matchedPolicyId ?? doc.matchedPolicyId;
let matchedCustomerId = doc.matchedCustomerId;
if (matchedPolicyId) {
const p = await this.prisma.policy.findUnique({
where: { id: matchedPolicyId },
select: { customerId: true },
});
if (!p) throw new BadRequestException("Póliza no encontrada.");
matchedCustomerId = p.customerId;
} else if (dto.matchedCustomerId) {
const c = await this.prisma.customer.findUnique({
where: { id: dto.matchedCustomerId },
select: { id: true },
});
if (!c) throw new BadRequestException("Cliente no encontrado.");
matchedCustomerId = c.id;
}
return this.prisma.policyOcrDocument.update({
where: { id },
data: {
extractedPolicyNumber: dto.policyNumber ?? undefined,
extractedInsuredName: dto.insuredName ?? undefined,
extractedAdditionalInsured: dto.additionalInsured ?? undefined,
extractedAgentName: dto.agentName ?? undefined,
extractedLegalAddress: dto.legalAddress ?? undefined,
extractedZip: dto.zip ?? undefined,
extractedPolicyFrom: dto.policyFrom ? new Date(dto.policyFrom) : undefined,
extractedPolicyTo: dto.policyTo ? new Date(dto.policyTo) : undefined,
extractedPolicyDate: dto.policyDate ? new Date(dto.policyDate) : undefined,
extractedCurrency: dto.currency ?? undefined,
extractedNetPremium:
dto.netPremium != null ? new Prisma.Decimal(dto.netPremium) : undefined,
extractedPolicyFee:
dto.policyFee != null ? new Prisma.Decimal(dto.policyFee) : undefined,
extractedBrokerFee:
dto.brokerFee != null ? new Prisma.Decimal(dto.brokerFee) : undefined,
extractedTotal:
dto.total != null ? new Prisma.Decimal(dto.total) : undefined,
extractedCoveragesJson: dto.coveragesJson
? (dto.coveragesJson as Prisma.InputJsonValue)
: undefined,
extractedPremiumPayment: dto.premiumPayment ?? undefined,
matchedPolicyId,
matchedCustomerId,
status: dto.forceConfirm ? "CONFIRMED" : "MATCHED",
reviewedById,
reviewedAt: new Date(),
},
});
}
async reject(id: string, reviewedById: string) {
const doc = await this.prisma.policyOcrDocument.findUnique({ where: { id } });
if (!doc) throw new NotFoundException("Documento no encontrado.");
if (doc.status === "POSTED") {
throw new BadRequestException("Este documento ya fue aplicado.");
}
return this.prisma.policyOcrDocument.update({
where: { id },
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
});
}
// --- confirm --------------------------------------------------------------
/**
* Apply every confirmed document: create or update the Policy, attach the
* source PDF as a PolicyDocument, and (when staff asked + premium parses)
* write a Transaction row. Each step is guarded by status checks so a
* double-confirm cannot re-apply a document.
*/
async confirmBatch(batchId: string, dto: ConfirmPolicyBatchDto, reviewedById: string) {
const batch = await this.prisma.policyOcrBatch.findUnique({ where: { id: batchId } });
if (!batch) throw new NotFoundException("Lote no encontrado.");
const results: { documentId: string; policyId: string; postedTransactionId: string | null }[] = [];
for (const item of dto.documents) {
const doc = await this.prisma.policyOcrDocument.findUnique({
where: { id: item.documentId },
});
if (!doc) {
throw new BadRequestException(`Documento ${item.documentId} no encontrado.`);
}
if (doc.status === "POSTED") {
throw new BadRequestException(
`El documento página ${doc.pageNumber} ya fue aplicado.`,
);
}
if (!item.policyId && !item.customerId) {
throw new BadRequestException(
`Documento página ${doc.pageNumber}: falta póliza destino o cliente.`,
);
}
// 1. Resolve target Policy (create or update). Field selection: every
// non-null `extracted*` on the doc (post-review) is written. Null is
// preserved — never overwrite an existing Policy's `netPremium` with
// null because the certificate page didn't carry one.
let policyId = item.policyId ?? null;
if (policyId) {
const updateData = buildPolicyUpdateFromDoc(item, doc);
await this.prisma.policy.update({
where: { id: policyId },
data: updateData,
});
} else {
// Create under the picked customer. `policyNumber` is the only field
// that must be present.
if (!item.policyNumber && !doc.extractedPolicyNumber) {
throw new BadRequestException(
`Documento página ${doc.pageNumber}: falta número de póliza.`,
);
}
const createData = buildPolicyCreateFromDoc(item, doc, item.customerId!);
const created = await this.prisma.policy.create({
data: createData,
});
policyId = created.id;
}
// 2. Attach the source PDF as a PolicyDocument. `doc.storageKey`
// already points at the exact upload (`policy-ocr/{batchId}/source-N.pdf`)
// so the attach is just a stream copy into the policy's namespace —
// the previous per-page "which file did this page come from" walk is
// gone because one PDF = one doc now.
await this.attachSourcePdf(doc.storageKey, policyId);
// 3. Optionally post the premium to the ledger. Only when staff
// explicitly asked (`postPremium` true) and netPremium parses — without
// that gate a missing premium would silently book $0.
let postedTransactionId: string | null = null;
const premium =
item.netPremium != null
? item.netPremium
: doc.extractedNetPremium != null
? Number(doc.extractedNetPremium)
: null;
if (item.postPremium && premium && premium > 0) {
const tx = await this.prisma.transaction.create({
data: {
customerId: (await this.policyCustomerId(policyId))!,
domain: "INSURANCE",
amount: new Prisma.Decimal(-Math.abs(premium)),
transactionDate: doc.extractedPolicyDate ?? doc.extractedPolicyFrom ?? new Date(),
currency: (item.currency ??
doc.extractedCurrency ??
"MXN") as Currency,
reference: item.policyNumber ?? doc.extractedPolicyNumber ?? null,
period: null,
captureSource: "OCR",
captureRef: doc.id,
message: `Prima de póliza ${item.policyNumber ?? doc.extractedPolicyNumber ?? ""}`,
},
});
postedTransactionId = tx.id;
}
await this.prisma.policyOcrDocument.update({
where: { id: doc.id },
data: {
status: "POSTED",
matchedPolicyId: policyId,
reviewedById,
reviewedAt: new Date(),
createdPolicyId: item.policyId ? null : policyId,
postedTransactionId,
},
});
results.push({
documentId: doc.id,
policyId,
postedTransactionId,
});
}
await this.closeIfDone(batchId);
return {
applied: results.length,
policies: results.map((r) => r.policyId),
postedTransactions: results.filter((r) => r.postedTransactionId).length,
};
}
/**
* Stream the source PDF (`sourceKey`, set by `process` on the doc row)
* into the policy's storage namespace and create a `PolicyDocument`
* pointer. Trivial now that the doc row holds the exact source key —
* the old per-page "which file did this page come from" walk is gone.
*/
private async attachSourcePdf(sourceKey: string, policyId: string): Promise<void> {
const got = await this.storage.getStream(sourceKey);
const chunks: Buffer[] = [];
for await (const c of got.stream) chunks.push(c as Buffer);
const buf = Buffer.concat(chunks);
const newKey = `policy/${policyId}/${Date.now()}-${crypto.randomUUID()}.pdf`;
await this.storage.put(newKey, buf, "application/pdf");
await this.prisma.policyDocument.create({
data: {
policyId,
documentType: "GMX_POLICY",
storageKey: newKey,
},
});
}
private async policyCustomerId(policyId: string): Promise<string | null> {
const p = await this.prisma.policy.findUnique({
where: { id: policyId },
select: { customerId: true },
});
return p?.customerId ?? null;
}
private async closeIfDone(batchId: string) {
const open = await this.prisma.policyOcrDocument.count({
where: {
batchId,
status: { in: ["PENDING_OCR", "NEEDS_REVIEW", "MATCHED", "CONFIRMED"] },
},
});
if (open === 0) {
await this.prisma.policyOcrBatch.update({
where: { id: batchId },
data: { status: "COMPLETED", completedAt: new Date() },
});
}
}
}
/** Map a (post-review) doc + final confirmed fields onto a `Policy.update`
* payload. Every field that is null in both inputs is omitted so we never
* write null over a value the Policy already carries (the GMX certificate
* has no premium — we must not blank the existing Policy.netPremium). */
function buildPolicyUpdateFromDoc(
item: ConfirmPolicyDocumentDto,
doc: {
extractedPolicyNumber: string | null;
extractedInsuredName: string | null;
extractedAdditionalInsured: string | null;
extractedAgentName: string | null;
extractedLegalAddress: string | null;
extractedZip: string | null;
extractedPolicyFrom: Date | null;
extractedPolicyTo: Date | null;
extractedPolicyDate: Date | null;
extractedCurrency: string | null;
extractedNetPremium: Prisma.Decimal | null;
extractedPolicyFee: Prisma.Decimal | null;
extractedBrokerFee: Prisma.Decimal | null;
extractedTotal: Prisma.Decimal | null;
extractedCoveragesJson: Prisma.JsonValue | null;
extractedPremiumPayment: string | null;
},
): Prisma.PolicyUpdateInput {
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
if (a != null) return new Prisma.Decimal(a);
if (b != null) return b;
return undefined;
};
const dateOrUndef = (a: string | undefined, b: Date | null): Date | undefined => {
if (a) return new Date(a);
if (b) return b;
return undefined;
};
const strOrUndef = (a: string | undefined, b: string | null): string | undefined => {
if (a != null && a !== "") return a;
if (b != null && b !== "") return b;
return undefined;
};
return {
policyNumber: strOrUndef(item.policyNumber, doc.extractedPolicyNumber),
agentName: strOrUndef(item.agentName, doc.extractedAgentName),
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
total: numOrUndef(item.total, doc.extractedTotal),
// coveragesJson / observations: freeform, keep the GMX data when present.
coveragesJson:
item.coveragesJson !== undefined
? (item.coveragesJson as Prisma.InputJsonValue)
: doc.extractedCoveragesJson != null
? (doc.extractedCoveragesJson as Prisma.InputJsonValue)
: undefined,
// Premium payment cadence ("CONTADO") and insured-name fields land in
// `observations` so the PolicyForm's edits stay the source of truth for
// structured fields. The reviewer can move them by hand if needed.
observations: joinObservations(
doc.extractedInsuredName,
doc.extractedAdditionalInsured,
doc.extractedLegalAddress,
doc.extractedZip,
doc.extractedPremiumPayment,
item,
),
};
}
/** Same shape as `buildPolicyUpdateFromDoc`, but for `Policy.create`. The
* `customerId` is supplied separately and `policyNumber` is required (a
* Policy without a number can't be re-matched by the OCR pipeline). */
function buildPolicyCreateFromDoc(
item: ConfirmPolicyDocumentDto,
doc: {
extractedPolicyNumber: string | null;
extractedInsuredName: string | null;
extractedAdditionalInsured: string | null;
extractedAgentName: string | null;
extractedLegalAddress: string | null;
extractedZip: string | null;
extractedPolicyFrom: Date | null;
extractedPolicyTo: Date | null;
extractedPolicyDate: Date | null;
extractedCurrency: string | null;
extractedNetPremium: Prisma.Decimal | null;
extractedPolicyFee: Prisma.Decimal | null;
extractedBrokerFee: Prisma.Decimal | null;
extractedTotal: Prisma.Decimal | null;
extractedCoveragesJson: Prisma.JsonValue | null;
extractedPremiumPayment: string | null;
},
customerId: string,
): Prisma.PolicyUncheckedCreateInput {
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
if (a != null) return new Prisma.Decimal(a);
if (b != null) return b;
return undefined;
};
const dateOrUndef = (a: string | undefined, b: Date | null): Date | undefined => {
if (a) return new Date(a);
if (b) return b;
return undefined;
};
const strOrUndef = (a: string | undefined, b: string | null): string | undefined => {
if (a != null && a !== "") return a;
if (b != null && b !== "") return b;
return undefined;
};
const policyNumber =
strOrUndef(item.policyNumber, doc.extractedPolicyNumber);
if (!policyNumber) {
// Caller already guards this; the throw is a type-narrowing aid.
throw new Error("policyNumber required for create");
}
return {
policyNumber,
customerId,
agentName: strOrUndef(item.agentName, doc.extractedAgentName),
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
total: numOrUndef(item.total, doc.extractedTotal),
coveragesJson:
item.coveragesJson !== undefined
? (item.coveragesJson as Prisma.InputJsonValue)
: doc.extractedCoveragesJson != null
? (doc.extractedCoveragesJson as Prisma.InputJsonValue)
: undefined,
observations: joinObservations(
doc.extractedInsuredName,
doc.extractedAdditionalInsured,
doc.extractedLegalAddress,
doc.extractedZip,
doc.extractedPremiumPayment,
item,
),
};
}
function joinObservations(
insured: string | null,
additional: string | null,
address: string | null,
zip: string | null,
premiumPayment: string | null,
item: ConfirmPolicyDocumentDto,
): string | undefined {
const lines: string[] = [];
const insuredName = strOrUndefDb(item.insuredName, insured);
if (insuredName) lines.push(`Asegurado: ${insuredName}`);
const additionalInsured = strOrUndefDb(item.additionalInsured, additional);
if (additionalInsured) lines.push(`Asegurado adicional: ${additionalInsured}`);
const legalAddress = strOrUndefDb(item.legalAddress, address);
if (legalAddress) lines.push(`Dirección: ${legalAddress}`);
const zipVal = strOrUndefDb(item.zip, zip);
if (zipVal) lines.push(`C.P.: ${zipVal}`);
const cadence = strOrUndefDb(item.premiumPayment, premiumPayment);
if (cadence) lines.push(`Pago de prima: ${cadence}`);
return lines.length ? lines.join("\n") : undefined;
}
function strOrUndefDb(a: string | undefined, b: string | null): string | undefined {
if (a != null && a !== "") return a;
if (b != null && b !== "") return b;
return undefined;
}
+6 -11
View File
@@ -1,23 +1,18 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { BillingModule } from "../billing/billing.module"; import { BillingModule } from "../billing/billing.module";
import { OcrModule } from "../ocr/ocr.module";
import { StatementsController } from "./statements.controller"; import { StatementsController } from "./statements.controller";
import { StatementsService } from "./statements.service"; import { StatementsService } from "./statements.service";
import { StatementMatcherService } from "./statement-matcher.service"; import { StatementMatcherService } from "./statement-matcher.service";
import { OCR_PROVIDER } from "./ocr/ocr.provider";
import { TesseractOcrProvider } from "./ocr/tesseract.provider";
/** /**
* The concrete OCR engine is bound here and nowhere else — everything * The concrete OCR engine is bound in OcrModule (see apps/api/src/ocr/) —
* downstream depends on the OcrProvider interface, so swapping Tesseract for a * everything downstream depends on the OcrProvider interface, so swapping
* managed extraction API is a one-line change in this file. * Tesseract for a managed extraction API is a one-line change there.
*/ */
@Module({ @Module({
imports: [BillingModule], imports: [BillingModule, OcrModule],
controllers: [StatementsController], controllers: [StatementsController],
providers: [ providers: [StatementsService, StatementMatcherService],
StatementsService,
StatementMatcherService,
{ provide: OCR_PROVIDER, useClass: TesseractOcrProvider },
],
}) })
export class StatementsModule {} export class StatementsModule {}
@@ -0,0 +1,16 @@
"use client";
import { AppShell } from "@/components/AppShell";
import { PolicyOcrReview } from "@/components/PolicyOcrReview";
export default function PolicyOcrBatchPage({
params,
}: {
params: { id: string };
}) {
return (
<AppShell>
<PolicyOcrReview id={params.id} />
</AppShell>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { AppShell } from "@/components/AppShell";
import { PolicyCaptura } from "@/components/PolicyCaptura";
/**
* OCR mode of the policy intake screen. Drops the GMX PDF, walks through
* per-page review, confirms. Same wrapper as `/polizas/nuevo` (manual)
* with `initialMode="auto"`, so the tab strip is identical and swapping
* modes doesn't drop state.
*
* Sister route `/polizas/captura/[id]` is the batch review screen once a
* batch is uploaded.
*/
export default function CapturaOcrPage() {
return (
<AppShell>
<PolicyCaptura initialMode="auto" />
</AppShell>
);
}
+9 -28
View File
@@ -1,41 +1,22 @@
"use client"; "use client";
import { Suspense } from "react"; import { Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { PolicyForm } from "@/components/PolicyForm"; import { PolicyCaptura } from "@/components/PolicyCaptura";
import { useCan } from "@/lib/abilities";
/**
* Manual mode of the policy intake screen. Shares the tab wrapper with
* `/polizas/captura` (OCR mode) so staff can swap between the two without
* losing their place. Customer picker comes from the `?customerId=`
* / `?customerName=` query string — used by `/clientes/[id]` when staff
* creates a policy from a customer detail page.
*/
export default function NuevaPolizaPage() { export default function NuevaPolizaPage() {
return ( return (
<AppShell> <AppShell>
<Suspense fallback={null}> <Suspense fallback={null}>
<NuevaPoliza /> <PolicyCaptura initialMode="manual" />
</Suspense> </Suspense>
</AppShell> </AppShell>
); );
} }
function NuevaPoliza() {
const allowed = useCan("policy:create");
const params = useSearchParams();
const customerId = params.get("customerId") ?? undefined;
const customerName = params.get("customerName") ?? undefined;
return (
<>
<div className="page-head">
<Link href="/polizas" className="back-link"> Pólizas</Link>
<h1 className="page-title">Nueva póliza</h1>
</div>
{allowed ? (
<PolicyForm fixedCustomerId={customerId} fixedCustomerName={customerName} />
) : (
<div className="state-box state-error">
No tiene permisos para crear pólizas.
</div>
)}
</>
);
}
+6
View File
@@ -57,6 +57,7 @@ export default function PolizasPage() {
function PolizasBrowser() { function PolizasBrowser() {
const canCreate = useCan("policy:create"); const canCreate = useCan("policy:create");
const canIngest = useCan("policy:ingest");
const [stats, setStats] = useState<PolicyStats | null>(null); const [stats, setStats] = useState<PolicyStats | null>(null);
const [facets, setFacets] = useState<PolicyFacets | null>(null); const [facets, setFacets] = useState<PolicyFacets | null>(null);
@@ -135,6 +136,11 @@ function PolizasBrowser() {
{ slug: "vigente", label: "Por vencer (Incen.)", params: { typeName: "INCEN" } }, { slug: "vigente", label: "Por vencer (Incen.)", params: { typeName: "INCEN" } },
]} ]}
/> />
{canIngest && (
<Link href="/polizas/captura" className="btn btn-outline">
+ Captura OCR
</Link>
)}
{canCreate && ( {canCreate && (
<Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link> <Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link>
)} )}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { PolicyForm } from "@/components/PolicyForm";
import { PolicyOcrIntake } from "@/components/PolicyOcrIntake";
import { useCan } from "@/lib/abilities";
/**
* Policy intake — mirror of `Captura.tsx` (statement OCR side): one screen,
* two ways in:
*
* - **manual** — `PolicyForm` keys every field by hand.
* - **auto** — `PolicyOcrIntake` uploads a GMX PDF, OCR proposes the
* policy, a human still confirms.
*
* Both end at the same place (a `Policy` row on a customer's file) so they
* live as two modes of one screen rather than two menu entries — exactly the
* same shape Captura uses for `ManualCheckCapture` vs `StatementIntake`.
*
* `/polizas/nuevo` opens manual, `/polizas/captura` opens auto; both render
* this component so the tab toggle works either way and an old bookmark
* still lands on the right tab.
*/
export type PolicyCaptureMode = "manual" | "auto";
const MODE_HINT: Record<PolicyCaptureMode, string> = {
manual:
"Captura cada campo a mano. Use esta opción cuando la póliza llega en papel, en un correo sin PDF legible, o cuando hay que revisar cada dato.",
auto: "Suelte el PDF descargado del portal de GMX y el sistema propondrá los campos. Nada se registra sin tu confirmación.",
};
export function PolicyCaptura({ initialMode = "manual" }: { initialMode?: PolicyCaptureMode }) {
const canCreate = useCan("policy:create");
const canIngest = useCan("policy:ingest");
// `/clientes/[id]` deep-links into /polizas/nuevo with the customer
// pre-picked so staff can fill the rest without retyping. The OCR pane
// ignores these — there's no customer to lock in until the batch is
// confirmed.
const params = useSearchParams();
const fixedCustomerId = params.get("customerId") ?? undefined;
const fixedCustomerName = params.get("customerName") ?? undefined;
// One user can land on either mode. The tab strip only renders when both
// abilities are held — a STAFF with only policy:ingest (no create) still
// sees the screen but only the OCR tab is offered.
const modes: { key: PolicyCaptureMode; label: string }[] = [
...(canCreate ? [{ key: "manual" as const, label: "Captura manual" }] : []),
...(canIngest ? [{ key: "auto" as const, label: "Captura automática (OCR)" }] : []),
];
const [mode, setMode] = useState<PolicyCaptureMode>(
modes.some((m) => m.key === initialMode) ? initialMode : (modes[0]?.key ?? "manual"),
);
if (modes.length === 0) {
return (
<div className="state-box state-error">
No tienes permiso para crear ni capturar pólizas.
</div>
);
}
return (
<>
<div className="page-head">
<Link href="/polizas" className="back-link"> Pólizas</Link>
<h1 className="page-title">Nueva póliza</h1>
<p className="eyebrow">{MODE_HINT[mode]}</p>
</div>
{modes.length > 1 && (
<div className="seg" role="tablist" style={{ marginBottom: 16 }}>
{modes.map((m) => (
<button
key={m.key}
type="button"
role="tab"
aria-selected={mode === m.key}
className={`seg-btn ${mode === m.key ? "active" : ""}`}
onClick={() => setMode(m.key)}
>
{m.label}
</button>
))}
</div>
)}
{mode === "manual" ? (
<ManualPane
fixedCustomerId={fixedCustomerId}
fixedCustomerName={fixedCustomerName}
/>
) : (
<PolicyOcrIntake />
)}
</>
);
}
function ManualPane({
fixedCustomerId,
fixedCustomerName,
}: {
fixedCustomerId?: string;
fixedCustomerName?: string;
}) {
const allowed = useCan("policy:create");
if (!allowed) {
return (
<div className="state-box state-error">
No tiene permisos para crear pólizas.
</div>
);
}
return (
<PolicyForm
fixedCustomerId={fixedCustomerId}
fixedCustomerName={fixedCustomerName}
/>
);
}
+232
View File
@@ -0,0 +1,232 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
getPolicyOcrStatus,
listPolicyOcrBatches,
uploadPolicyOcrBatch,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatDate } from "@/lib/labels";
import type { PolicyOcrBatch, PolicyOcrBatchStatus } from "@/lib/types";
/**
* Insurance OCR intake — mirror of StatementIntake, scoped to the insurance
* side. Today the only provider is GMX; the parser dispatches on a brand
* wordmark (`Grupo Mexicano de Seguros` / `gmx.com.mx` / the GMX letterhead)
* and a new portal only needs a new BRAND entry plus a parser file.
*
* 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
* hand that day, mixed service vs insurance), and the matching/review queue
* already keys on the policyNumber → existing Policy transition that the
* rest of /polizas owns.
*/
const STATUS_LABEL: Record<PolicyOcrBatchStatus, string> = {
UPLOADED: "Recibido",
PROCESSING: "Procesando…",
READY_FOR_REVIEW: "Listo para revisar",
COMPLETED: "Aplicado",
FAILED: "Falló",
};
export function PolicyOcrIntake() {
const canIngest = useCan("policy:ingest");
const [batches, setBatches] = useState<PolicyOcrBatch[]>([]);
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null);
const [storageAvailable, setStorageAvailable] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const [list, status] = await Promise.all([
listPolicyOcrBatches(),
getPolicyOcrStatus(),
]);
setBatches(list.items);
setOcrAvailable(status.ocrAvailable);
setStorageAvailable(status.storageAvailable);
setError(null);
} catch (e) {
setError((e as Error)?.message ?? "No se pudieron cargar los lotes.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const working = batches.some(
(b) => b.status === "PROCESSING" || b.status === "UPLOADED",
);
useEffect(() => {
if (!working) return;
const t = setInterval(() => void load(), 4000);
return () => clearInterval(t);
}, [working, load]);
const ready = ocrAvailable === true && storageAvailable === true;
return (
<div className="stack">
{ocrAvailable === false && (
<div className="state-box state-error">
Este servidor no tiene OCR instalado, así que no se pueden leer PDFs
de pólizas escaneados. La captura manual sigue funcionando.
</div>
)}
{storageAvailable === false && (
<div className="state-box state-error">
Este servidor no tiene configurado el almacenamiento de documentos, así
que no hay dónde guardar los PDFs. Mientras tanto, capture las
pólizas a mano.
</div>
)}
{canIngest && ready && <UploadCard onDone={load} />}
{error && <div className="state-box state-error">{error}</div>}
<section className="card" style={{ padding: 16 }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
Lotes
</h2>
{loading ? (
<div className="state-box">Cargando</div>
) : batches.length === 0 ? (
<div className="state-box">
Todavía no hay lotes de pólizas. Descargue el certificado del portal
de GMX y suéltelo arriba.
</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Aseguradora</th>
<th>Referencia</th>
<th>Estado</th>
<th className="num">Páginas</th>
<th>Subido por</th>
<th />
</tr>
</thead>
<tbody>
{batches.map((b) => (
<tr key={b.id}>
<td style={{ whiteSpace: "nowrap" }}>{formatDate(b.createdAt)}</td>
<td>{b.provider}</td>
<td>{b.label || "—"}</td>
<td>
<StatusTag status={b.status} />
{b.error && (
<div className="page-sub" style={{ marginTop: 4 }}>
{b.error}
</div>
)}
</td>
<td className="num">{b._count?.documents ?? 0}</td>
<td>{b.uploadedBy?.name ?? "—"}</td>
<td>
<Link
className="btn btn-ghost"
href={`/polizas/captura/${b.id}`}
>
Revisar
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
function StatusTag({ status }: { status: PolicyOcrBatchStatus }) {
return <span className="tag">{STATUS_LABEL[status] ?? status}</span>;
}
function UploadCard({ onDone }: { onDone: () => void }) {
const [files, setFiles] = useState<File[]>([]);
const [label, setLabel] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
if (!files.length) return;
setBusy(true);
setError(null);
try {
await uploadPolicyOcrBatch(files, label.trim() || undefined);
setFiles([]);
setLabel("");
onDone();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo subir el lote.");
} finally {
setBusy(false);
}
}
return (
<section className="card" style={{ padding: 16 }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
Subir PDFs de pólizas (GMX)
</h2>
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<label>
<span className="page-sub">Referencia (opcional)</span>
<input
className="input"
placeholder="ej. GMX julio 2026"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</label>
<label>
<span className="page-sub">Archivos PDF</span>
<input
type="file"
className="input"
accept="application/pdf"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
/>
</label>
<button
type="button"
className="btn btn-primary"
disabled={!files.length || busy}
onClick={submit}
>
{busy ? "Subiendo…" : `Procesar ${files.length || ""}`.trim()}
</button>
</div>
{error && (
<div className="state-box state-error" style={{ marginTop: 12 }}>
{error}
</div>
)}
<p className="page-sub" style={{ marginTop: 12 }}>
Un lote puede traer varios PDFs. Cada página se procesa por separado; el
sistema busca una póliza existente por número y, si no la encuentra,
propone crear una nueva bajo el cliente que se elija en la revisión.
</p>
</section>
);
}
+578
View File
@@ -0,0 +1,578 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { CustomerPicker } from "@/components/CustomerPicker";
import {
confirmPolicyOcrBatch,
getPolicyOcrBatch,
listCustomers,
listPolicyOcrDocuments,
policyOcrDocumentUrl,
rejectPolicyOcrDocument,
reviewPolicyOcrDocument,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels";
import type {
CustomerListItem,
PolicyOcrBatchDetail,
PolicyOcrConfirmDocument,
PolicyOcrCoverage,
PolicyOcrDocument,
PolicyOcrReviewInput,
} from "@/lib/types";
const STATUS_LABEL: Record<string, string> = {
PENDING_OCR: "Pendiente",
OCR_FAILED: "Falló OCR",
NEEDS_REVIEW: "Para revisar",
MATCHED: "Listo",
CONFIRMED: "Confirmado",
POSTED: "Aplicado",
REJECTED: "Rechazado",
};
const OPEN_FIRST = [
"NEEDS_REVIEW",
"MATCHED",
"CONFIRMED",
"PENDING_OCR",
"OCR_FAILED",
"REJECTED",
"POSTED",
];
type EditMap = Record<string, PolicyOcrConfirmDocument | undefined>;
export function PolicyOcrReview({ id }: { id: string }) {
const canReview = useCan("policy:ocr-review");
const [batch, setBatch] = useState<PolicyOcrBatchDetail | null>(null);
const [docs, setDocs] = useState<PolicyOcrDocument[]>([]);
const [edits, setEdits] = useState<EditMap>({});
const [customerIndex, setCustomerIndex] = useState<Record<string, CustomerListItem>>({});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const load = useCallback(async () => {
try {
const [b, d, c] = await Promise.all([
getPolicyOcrBatch(id),
listPolicyOcrDocuments(id),
canReview ? listCustomers({ pageSize: 200 }).then((r) => r.items) : Promise.resolve([]),
]);
setBatch(b);
setDocs(d);
setCustomerIndex(Object.fromEntries(c.map((x) => [x.id, x])));
setError(null);
} catch (e) {
setError((e as Error)?.message ?? "No se pudo cargar el lote.");
} finally {
setLoading(false);
}
}, [id, canReview]);
useEffect(() => {
void load();
}, [load]);
const processing = batch?.status === "PROCESSING" || batch?.status === "UPLOADED";
useEffect(() => {
if (!processing) return;
const t = setInterval(() => void load(), 4000);
return () => clearInterval(t);
}, [processing, load]);
const sorted = useMemo(
() =>
[...docs].sort(
(a, b) =>
OPEN_FIRST.indexOf(a.status) - OPEN_FIRST.indexOf(b.status) ||
a.pageNumber - b.pageNumber,
),
[docs],
);
const readyCount = Object.values(edits).filter(Boolean).length;
function setEdit(docId: string, edit: PolicyOcrConfirmDocument) {
setEdits((prev) => ({ ...prev, [docId]: edit }));
}
async function onConfirm() {
if (!batch) return;
const payload: PolicyOcrConfirmDocument[] = [];
for (const d of docs) {
const edit = edits[d.id];
if (!edit) continue;
if (!edit.policyId && !edit.customerId) {
setError(`Página ${d.pageNumber}: falta cliente o póliza destino.`);
return;
}
payload.push(edit);
}
if (!payload.length) {
setError("No hay documentos revisados. Guarde cada página antes de aplicar.");
return;
}
setSubmitting(true);
setError(null);
try {
await confirmPolicyOcrBatch(batch.id, { documents: payload });
setEdits({});
await load();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo aplicar el lote.");
} finally {
setSubmitting(false);
}
}
if (loading) return <div className="state-box">Cargando</div>;
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
return (
<div className="stack">
<header className="page-head">
<div>
<h1 className="page-title">
Pólizas {batch.provider}
{batch.label ? ` · ${batch.label}` : ""}
</h1>
<p className="page-sub">
{formatDate(batch.createdAt)} · {docs.length} página(s) ·{" "}
{STATUS_LABEL[batch.status] ?? batch.status}
</p>
</div>
<Link className="btn btn-ghost" href="/polizas">
Volver a pólizas
</Link>
</header>
{processing && <div className="state-box">Procesando</div>}
{error && <div className="state-box state-error">{error}</div>}
{canReview && readyCount > 0 && (
<section className="card" style={{ padding: 16 }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
Aplicar lote
</h2>
<p className="page-sub" style={{ marginBottom: 12 }}>
{readyCount} página(s) revisada(s). Se creará o actualizará la póliza
y, si marcó la casilla, se registrará la prima en el estado de
cuenta.
</p>
<button
type="button"
className="btn btn-primary"
disabled={submitting}
onClick={onConfirm}
>
{submitting ? "Aplicando…" : "Aplicar"}
</button>
</section>
)}
<section className="stack">
{sorted.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
customerIndex={customerIndex}
canReview={canReview}
onSave={async (edit) => {
await reviewPolicyOcrDocument(doc.id, edit.reviewInput);
setEdit(doc.id, edit.confirmInput);
await load();
}}
onReject={async () => {
await rejectPolicyOcrDocument(doc.id);
setEdits((prev) => {
const { [doc.id]: _, ...rest } = prev;
return rest;
});
await load();
}}
/>
))}
</section>
</div>
);
}
interface RowSaved {
reviewInput: PolicyOcrReviewInput;
confirmInput: PolicyOcrConfirmDocument;
}
interface DocumentRowProps {
doc: PolicyOcrDocument;
customerIndex: Record<string, CustomerListItem>;
canReview: boolean;
onSave: (saved: RowSaved) => Promise<void>;
onReject: () => Promise<void>;
}
function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: DocumentRowProps) {
const [v, setV] = useState({
policyNumber: doc.extractedPolicyNumber ?? "",
insuredName: doc.extractedInsuredName ?? "",
additionalInsured: doc.extractedAdditionalInsured ?? "",
agentName: doc.extractedAgentName ?? "",
legalAddress: doc.extractedLegalAddress ?? "",
zip: doc.extractedZip ?? "",
policyFrom: doc.extractedPolicyFrom?.slice(0, 10) ?? "",
policyTo: doc.extractedPolicyTo?.slice(0, 10) ?? "",
policyDate: doc.extractedPolicyDate?.slice(0, 10) ?? "",
currency: doc.extractedCurrency ?? "USD",
netPremium: doc.extractedNetPremium ?? "",
total: doc.extractedTotal ?? "",
premiumPayment: doc.extractedPremiumPayment ?? "",
postPremium: doc.extractedNetPremium != null && Number(doc.extractedNetPremium) > 0,
});
const [customerId, setCustomerId] = useState(
doc.matchedCustomer?.id ?? doc.matchedPolicy?.customerId ?? "",
);
const [customerName, setCustomerName] = useState(
doc.matchedCustomer?.name ?? doc.matchedPolicy?.customer.name ?? "",
);
const [policyId, setPolicyId] = useState(doc.matchedPolicy?.id ?? "");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
function set<K extends keyof typeof v>(k: K, val: (typeof v)[K]) {
setV((p) => ({ ...p, [k]: val }));
}
async function save() {
setBusy(true);
setErr(null);
try {
const numOrUndef = (s: string) => (s.trim() === "" ? undefined : Number(s));
const trimOrUndef = (s: string) => (s.trim() === "" ? undefined : s.trim());
const currency = v.currency || undefined;
const reviewInput: PolicyOcrReviewInput = {
policyNumber: trimOrUndef(v.policyNumber),
insuredName: trimOrUndef(v.insuredName),
additionalInsured: trimOrUndef(v.additionalInsured),
agentName: trimOrUndef(v.agentName),
legalAddress: trimOrUndef(v.legalAddress),
zip: trimOrUndef(v.zip),
policyFrom: v.policyFrom || undefined,
policyTo: v.policyTo || undefined,
policyDate: v.policyDate || undefined,
currency,
netPremium: numOrUndef(v.netPremium),
total: numOrUndef(v.total),
premiumPayment: trimOrUndef(v.premiumPayment),
matchedPolicyId: policyId || undefined,
matchedCustomerId: !policyId && customerId ? customerId : undefined,
forceConfirm: true,
};
const confirmInput: PolicyOcrConfirmDocument = {
documentId: doc.id,
policyId: policyId || undefined,
customerId: !policyId && customerId ? customerId : undefined,
policyNumber: reviewInput.policyNumber,
insuredName: reviewInput.insuredName,
additionalInsured: reviewInput.additionalInsured,
agentName: reviewInput.agentName,
legalAddress: reviewInput.legalAddress,
zip: reviewInput.zip,
policyFrom: reviewInput.policyFrom,
policyTo: reviewInput.policyTo,
policyDate: reviewInput.policyDate,
currency: (currency as "MXN" | "USD" | "EUR" | undefined) ?? undefined,
netPremium: reviewInput.netPremium,
total: reviewInput.total,
premiumPayment: reviewInput.premiumPayment,
coveragesJson: (doc.extractedCoveragesJson ?? undefined) as
| PolicyOcrCoverage[]
| undefined,
postPremium: v.postPremium,
};
await onSave({ reviewInput, confirmInput });
} catch (e) {
setErr((e as Error)?.message ?? "No se pudo guardar.");
} finally {
setBusy(false);
}
}
const locked = doc.status === "POSTED" || doc.status === "REJECTED";
const matchedExisting = !!doc.matchedPolicy;
const candidates = doc.matchCandidates ?? [];
return (
<article className="card" style={{ padding: 16 }}>
<header className="row" style={{ gap: 12, alignItems: "center" }}>
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>
<span className="page-sub">Página {doc.pageNumber}</span>
{doc.extractedPolicyNumber && (
<strong style={{ marginLeft: 8 }}>{doc.extractedPolicyNumber}</strong>
)}
{doc.extractedInsuredName && (
<span className="page-sub">· {doc.extractedInsuredName}</span>
)}
</header>
<div className="doc-detail">
{/*
* Embed the source PDF the office uploaded. One PDF = one parsed
* policy, so the browser's PDF viewer handles multi-page navigation
* natively; we don't need to render individual pages on the server.
*/}
<iframe
src={policyOcrDocumentUrl(doc.id)}
title={`Póliza ${doc.extractedPolicyNumber ?? doc.pageNumber}`}
style={{
width: "100%",
height: 720,
border: "1px solid var(--border, #ddd)",
borderRadius: 6,
background: "#fff",
}}
/>
<div className="stack" style={{ flex: 1, minWidth: 0 }}>
{doc.matchNote && <p className="page-sub">{doc.matchNote}</p>}
{matchedExisting ? (
<div className="state-box">
Coincide con la póliza{" "}
<strong>{doc.matchedPolicy?.policyNumber}</strong> del cliente{" "}
<strong>{doc.matchedPolicy?.customer.name}</strong>.
</div>
) : candidates.length > 1 ? (
<div className="state-box state-warn">
{candidates.length} pólizas comparten este número. Elija
manualmente abajo.
</div>
) : (
<div className="state-box">
No se encontró una póliza con este número. Se creará una nueva
bajo el cliente que elija abajo.
</div>
)}
<fieldset className="form-grid" disabled={locked || !canReview}>
<Field label="Número de póliza">
<input
className="input"
value={v.policyNumber}
onChange={(e) => set("policyNumber", e.target.value)}
/>
</Field>
<Field label="Asegurado">
<input
className="input"
value={v.insuredName}
onChange={(e) => set("insuredName", e.target.value)}
/>
</Field>
<Field label="Asegurado adicional">
<input
className="input"
value={v.additionalInsured}
onChange={(e) => set("additionalInsured", e.target.value)}
/>
</Field>
<Field label="Agente">
<input
className="input"
value={v.agentName}
onChange={(e) => set("agentName", e.target.value)}
/>
</Field>
<Field label="Desde">
<input
className="input"
type="date"
value={v.policyFrom}
onChange={(e) => set("policyFrom", e.target.value)}
/>
</Field>
<Field label="Hasta">
<input
className="input"
type="date"
value={v.policyTo}
onChange={(e) => set("policyTo", e.target.value)}
/>
</Field>
<Field label="Fecha de firma">
<input
className="input"
type="date"
value={v.policyDate}
onChange={(e) => set("policyDate", e.target.value)}
/>
</Field>
<Field label="Moneda">
<select
className="input select"
value={v.currency}
onChange={(e) => set("currency", e.target.value)}
>
<option value="MXN">MXN</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</Field>
<Field label="Prima neta">
<input
className="input"
type="number"
step="0.01"
value={v.netPremium}
onChange={(e) => set("netPremium", e.target.value)}
/>
</Field>
<Field label="Total">
<input
className="input"
type="number"
step="0.01"
value={v.total}
onChange={(e) => set("total", e.target.value)}
/>
</Field>
<Field label="Pago de prima">
<input
className="input"
value={v.premiumPayment}
onChange={(e) => set("premiumPayment", e.target.value)}
/>
</Field>
<Field label="Dirección">
<input
className="input"
value={v.legalAddress}
onChange={(e) => set("legalAddress", e.target.value)}
/>
</Field>
<Field label="C.P.">
<input
className="input"
value={v.zip}
onChange={(e) => set("zip", e.target.value)}
/>
</Field>
</fieldset>
{doc.extractedCoveragesJson && doc.extractedCoveragesJson.length > 0 && (
<details>
<summary>
Coberturas ({doc.extractedCoveragesJson.length}) ·{" "}
{formatMoney(
doc.extractedCoveragesJson
.map((c) => Number(c.insuredAmount ?? 0))
.reduce((a, b) => a + b, 0)
.toString(),
v.currency,
)}
</summary>
<table className="tx-table" style={{ marginTop: 8 }}>
<thead>
<tr>
<th>Riesgo</th>
<th className="num">Suma</th>
<th>Deducible</th>
<th>Participación</th>
</tr>
</thead>
<tbody>
{doc.extractedCoveragesJson.map((c, i) => (
<tr key={i}>
<td>{c.risk}</td>
<td className="num">
{formatMoney(c.insuredAmount?.toString() ?? null, v.currency)}
</td>
<td>{c.deductible ?? "—"}</td>
<td>{c.lossParticipation ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</details>
)}
{candidates.length > 1 && (
<Field label="Póliza destino">
<select
className="input select"
value={policyId}
onChange={(e) => {
setPolicyId(e.target.value);
const found = candidates.find((c) => c.policyId === e.target.value);
if (found) {
setCustomerId(found.customerId);
setCustomerName(customerIndex[found.customerId]?.name ?? found.customerName);
}
}}
>
<option value=""> elegir póliza </option>
{candidates.map((c) => (
<option key={c.policyId} value={c.policyId}>
{c.policyNumber} · {c.customerName}
</option>
))}
</select>
</Field>
)}
{!policyId && (
<Field label={matchedExisting ? "Cliente" : "Cliente (póliza nueva)"}>
<CustomerPicker
value={customerId}
valueName={customerName}
onPick={(id, name) => {
setCustomerId(id);
setCustomerName(name);
}}
/>
</Field>
)}
<label className="field">
<input
type="checkbox"
checked={v.postPremium}
onChange={(e) => set("postPremium", e.target.checked)}
disabled={!v.netPremium || Number(v.netPremium) <= 0}
/>{" "}
Registrar prima en el estado de cuenta
</label>
{err && <div className="state-box state-error">{err}</div>}
{!locked && canReview && (
<div className="row" style={{ gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
{busy ? "Guardando…" : "Guardar revisión"}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => void onReject()}
>
Rechazar
</button>
</div>
)}
</div>
</div>
</article>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="field">
<span className="field-label">{label}</span>
{children}
</label>
);
}
+99
View File
@@ -49,6 +49,12 @@ import type {
PolicySort, PolicySort,
PolicyStats, PolicyStats,
PolicyStatus, PolicyStatus,
PolicyOcrBatch,
PolicyOcrBatchDetail,
PolicyOcrDocument,
PolicyOcrReviewInput,
PolicyOcrConfirmInput,
PolicyOcrConfirmResult,
LookupsResponse, LookupsResponse,
OpsJob, OpsJob,
OpsJobKind, OpsJobKind,
@@ -1077,3 +1083,96 @@ export function confirmStatementBatch(
export function statementPageUrl(documentId: string): string { export function statementPageUrl(documentId: string): string {
return `${API_ORIGIN}/statements/documents/${documentId}/page`; return `${API_ORIGIN}/statements/documents/${documentId}/page`;
} }
/* ----------------------------------------------------- Policy OCR (GMX) */
export function getPolicyOcrStatus(): Promise<{
ocrAvailable: boolean;
storageAvailable: boolean;
}> {
return apiFetch("/policy-ocr/status");
}
export function listPolicyOcrBatches(
page = 1,
pageSize = 25,
): Promise<{
items: PolicyOcrBatch[];
total: number;
page: number;
pageSize: number;
pageCount: number;
}> {
return apiFetch(`/policy-ocr/batches?page=${page}&pageSize=${pageSize}`);
}
export function getPolicyOcrBatch(id: string): Promise<PolicyOcrBatchDetail> {
return apiFetch(`/policy-ocr/batches/${id}`);
}
export function listPolicyOcrDocuments(batchId: string): Promise<PolicyOcrDocument[]> {
return apiFetch(`/policy-ocr/batches/${batchId}/documents`);
}
export async function uploadPolicyOcrBatch(
files: File[],
label?: string,
): Promise<PolicyOcrBatch> {
const body = new FormData();
for (const f of files) body.append("files", f, f.name);
const qs = new URLSearchParams();
if (label) qs.set("label", label);
const res = await fetch(
`${API_ORIGIN}/policy-ocr/batches${qs.toString() ? `?${qs}` : ""}`,
{
method: "POST",
credentials: "include",
body,
},
);
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const b = await res.json();
if (b?.message) message = b.message;
} catch {
/* non-JSON error body */
}
throw new Error(message);
}
return res.json();
}
export function reviewPolicyOcrDocument(
id: string,
input: PolicyOcrReviewInput,
): Promise<PolicyOcrDocument> {
return apiFetch(`/policy-ocr/documents/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function rejectPolicyOcrDocument(id: string): Promise<PolicyOcrDocument> {
return apiFetch(`/policy-ocr/documents/${id}/reject`, { method: "POST" });
}
export function confirmPolicyOcrBatch(
batchId: string,
input: PolicyOcrConfirmInput,
): Promise<PolicyOcrConfirmResult> {
return apiFetch(`/policy-ocr/batches/${batchId}/confirm`, {
method: "POST",
body: JSON.stringify(input),
});
}
/**
* URL for the source PDF of a parsed policy document. The endpoint returns
* the original upload (one PDF = one parsed policy), not a rendered page
* image, so the review screen embeds it in an iframe.
*/
export function policyOcrDocumentUrl(documentId: string): string {
return `${API_ORIGIN}/policy-ocr/documents/${documentId}/page`;
}
+139
View File
@@ -12,6 +12,8 @@ export type Ability =
| "policy:create" | "policy:create"
| "policy:update" | "policy:update"
| "policy:delete" | "policy:delete"
| "policy:ingest"
| "policy:ocr-review"
| "property:create" | "property:create"
| "property:update" | "property:update"
| "property:delete" | "property:delete"
@@ -1293,3 +1295,140 @@ export interface ConfirmBatchResult {
total: string; total: string;
checkNumber: string; checkNumber: string;
} }
/* ------------------------------------------ Policy OCR intake (GMX) */
export type PolicyOcrBatchStatus =
| "UPLOADED"
| "PROCESSING"
| "READY_FOR_REVIEW"
| "COMPLETED"
| "FAILED";
export type PolicyOcrDocumentStatus =
| "PENDING_OCR"
| "OCR_FAILED"
| "NEEDS_REVIEW"
| "MATCHED"
| "CONFIRMED"
| "POSTED"
| "REJECTED";
export interface PolicyOcrBatch {
id: string;
provider: string;
status: PolicyOcrBatchStatus;
label: string | null;
fileCount: number;
error: string | null;
createdAt: string;
completedAt: string | null;
uploadedBy?: { name: string };
_count?: { documents: number };
}
export interface PolicyOcrBatchDetail extends PolicyOcrBatch {
byStatus: Partial<Record<PolicyOcrDocumentStatus, number>>;
}
export interface PolicyOcrCoverage {
risk: string;
insuredAmount: number | null;
deductible: string | null;
lossParticipation: string | null;
}
export interface PolicyOcrMatchCandidate {
policyId: string;
customerId: string;
customerName: string;
policyNumber: string;
}
export interface PolicyOcrDocument {
id: string;
pageNumber: number;
status: PolicyOcrDocumentStatus;
provider: string | null;
ocrConfidence: string | null;
extractedPolicyNumber: string | null;
extractedInsuredName: string | null;
extractedAdditionalInsured: string | null;
extractedAgentName: string | null;
extractedLegalAddress: string | null;
extractedZip: string | null;
extractedPolicyFrom: string | null;
extractedPolicyTo: string | null;
extractedPolicyDate: string | null;
extractedCurrency: string | null;
extractedNetPremium: string | null;
extractedPolicyFee: string | null;
extractedBrokerFee: string | null;
extractedTotal: string | null;
extractedCoveragesJson: PolicyOcrCoverage[] | null;
extractedPremiumPayment: string | null;
matchedPolicy: {
id: string;
policyNumber: string | null;
customerId: string;
customer: { name: string };
} | null;
matchedCustomer: { id: string; name: string } | null;
matchCandidates: PolicyOcrMatchCandidate[] | null;
matchNote: string | null;
}
export interface PolicyOcrReviewInput {
policyNumber?: string;
insuredName?: string;
additionalInsured?: string;
agentName?: string;
legalAddress?: string;
zip?: string;
policyFrom?: string;
policyTo?: string;
policyDate?: string;
currency?: string;
netPremium?: number;
policyFee?: number;
brokerFee?: number;
total?: number;
premiumPayment?: string;
coveragesJson?: PolicyOcrCoverage[];
matchedPolicyId?: string;
matchedCustomerId?: string;
forceConfirm?: boolean;
}
export interface PolicyOcrConfirmDocument {
documentId: string;
policyId?: string;
customerId?: string;
policyNumber?: string;
insuredName?: string;
additionalInsured?: string;
agentName?: string;
legalAddress?: string;
zip?: string;
policyFrom?: string;
policyTo?: string;
policyDate?: string;
currency?: string;
netPremium?: number;
policyFee?: number;
brokerFee?: number;
total?: number;
premiumPayment?: string;
coveragesJson?: PolicyOcrCoverage[];
postPremium?: boolean;
}
export interface PolicyOcrConfirmInput {
documents: PolicyOcrConfirmDocument[];
}
export interface PolicyOcrConfirmResult {
applied: number;
policies: string[];
postedTransactions: number;
}
@@ -0,0 +1,80 @@
-- CreateTable
CREATE TABLE `policy_ocr_batches` (
`id` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191) NOT NULL DEFAULT 'GMX',
`status` ENUM('UPLOADED', 'PROCESSING', 'READY_FOR_REVIEW', 'COMPLETED', 'FAILED') NOT NULL DEFAULT 'UPLOADED',
`uploadedById` VARCHAR(191) NOT NULL,
`label` VARCHAR(191) NULL,
`fileCount` INTEGER NOT NULL DEFAULT 0,
`error` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`completedAt` DATETIME(3) NULL,
INDEX `policy_ocr_batches_status_createdAt_idx`(`status`, `createdAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `policy_ocr_documents` (
`id` VARCHAR(191) NOT NULL,
`batchId` VARCHAR(191) NOT NULL,
`pageNumber` INTEGER NOT NULL,
`storageKey` VARCHAR(191) NOT NULL,
`status` ENUM('PENDING_OCR', 'OCR_FAILED', 'NEEDS_REVIEW', 'MATCHED', 'CONFIRMED', 'POSTED', 'REJECTED') NOT NULL DEFAULT 'PENDING_OCR',
`ocrRawText` TEXT NULL,
`ocrConfidence` DECIMAL(4, 3) NULL,
`provider` VARCHAR(191) NULL,
`extractedPolicyNumber` VARCHAR(191) NULL,
`extractedInsuredName` VARCHAR(191) NULL,
`extractedAdditionalInsured` VARCHAR(191) NULL,
`extractedAgentName` VARCHAR(191) NULL,
`extractedLegalAddress` TEXT NULL,
`extractedZip` VARCHAR(191) NULL,
`extractedPolicyFrom` DATETIME(3) NULL,
`extractedPolicyTo` DATETIME(3) NULL,
`extractedPolicyDate` DATETIME(3) NULL,
`extractedCurrency` VARCHAR(191) NULL,
`extractedNetPremium` DECIMAL(12, 2) NULL,
`extractedPolicyFee` DECIMAL(12, 2) NULL,
`extractedBrokerFee` DECIMAL(12, 2) NULL,
`extractedTotal` DECIMAL(12, 2) NULL,
`extractedCoveragesJson` JSON NULL,
`extractedPremiumPayment` VARCHAR(191) NULL,
`matchedPolicyId` VARCHAR(191) NULL,
`matchedCustomerId` VARCHAR(191) NULL,
`matchCandidates` JSON NULL,
`matchNote` VARCHAR(191) NULL,
`reviewedById` VARCHAR(191) NULL,
`reviewedAt` DATETIME(3) NULL,
`createdPolicyId` VARCHAR(191) NULL,
`postedTransactionId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `policy_ocr_documents_createdPolicyId_key`(`createdPolicyId`),
UNIQUE INDEX `policy_ocr_documents_postedTransactionId_key`(`postedTransactionId`),
INDEX `policy_ocr_documents_status_idx`(`status`),
INDEX `policy_ocr_documents_matchedCustomerId_idx`(`matchedCustomerId`),
UNIQUE INDEX `policy_ocr_documents_batchId_pageNumber_key`(`batchId`, `pageNumber`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `policy_ocr_batches` ADD CONSTRAINT `policy_ocr_batches_uploadedById_fkey` FOREIGN KEY (`uploadedById`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_batchId_fkey` FOREIGN KEY (`batchId`) REFERENCES `policy_ocr_batches`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_matchedPolicyId_fkey` FOREIGN KEY (`matchedPolicyId`) REFERENCES `policies`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_matchedCustomerId_fkey` FOREIGN KEY (`matchedCustomerId`) REFERENCES `customers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_reviewedById_fkey` FOREIGN KEY (`reviewedById`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_createdPolicyId_fkey` FOREIGN KEY (`createdPolicyId`) REFERENCES `policies`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_postedTransactionId_fkey` FOREIGN KEY (`postedTransactionId`) REFERENCES `transactions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
+213 -97
View File
@@ -6,8 +6,8 @@
// back to their Access original and the ETL can be re-run idempotently. // back to their Access original and the ETL can be re-run idempotently.
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "../generated/client" output = "../generated/client"
// "native" covers local dev. The musl target is declared EXPLICITLY because // "native" covers local dev. The musl target is declared EXPLICITLY because
// Prisma picks the engine by sniffing the build environment: the Docker build // Prisma picks the engine by sniffing the build environment: the Docker build
// stage has no openssl, so it detected plain "linux-musl", while the runtime // stage has no openssl, so it detected plain "linux-musl", while the runtime
@@ -78,42 +78,42 @@ enum UserRole {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
model Customer { model Customer {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
// Which legacy table `name` actually came from. Null = DATGRAL.NOMBRE, the // Which legacy table `name` actually came from. Null = DATGRAL.NOMBRE, the
// normal case. Anything else means DATGRAL's name was blank and the name was // normal case. Anything else means DATGRAL's name was blank and the name was
// recovered from a secondary table (see migration/transform_customers.py), // recovered from a secondary table (see migration/transform_customers.py),
// so staff can tell a reconstructed name from an original one. // so staff can tell a reconstructed name from an original one.
nameSource String? nameSource String?
// True when `name` is the "(SIN NOMBRE)" placeholder. Denormalized so lists // True when `name` is the "(SIN NOMBRE)" placeholder. Denormalized so lists
// can sort nameless records last — ordering by `name` alone puts them first, // can sort nameless records last — ordering by `name` alone puts them first,
// since "(" sorts before every letter. // since "(" sorts before every letter.
nameMissing Boolean @default(false) nameMissing Boolean @default(false)
addressLine1 String? addressLine1 String?
addressLine2 String? addressLine2 String?
city String? city String?
state String? state String?
zipCode String? zipCode String?
country String? country String?
phone String? phone String?
mobile String? mobile String?
fax String? fax String?
email String? email String?
notes String? @db.Text notes String? @db.Text
identificationType String? identificationType String?
identificationNumber String? identificationNumber String?
identificationExpiration DateTime? identificationExpiration DateTime?
customerSince DateTime? customerSince DateTime?
status Boolean @default(true) status Boolean @default(true)
minimumBalance Decimal? @db.Decimal(12, 2) minimumBalance Decimal? @db.Decimal(12, 2)
feeAmount Decimal? @db.Decimal(12, 2) feeAmount Decimal? @db.Decimal(12, 2)
preferredCurrency Currency @default(USD) preferredCurrency Currency @default(USD)
// Soft-delete marker. Distinct from `status` (a legacy business flag): a // Soft-delete marker. Distinct from `status` (a legacy business flag): a
// non-null archivedAt hides the row from default lists while preserving it // non-null archivedAt hides the row from default lists while preserving it
// and its legacy provenance. Never hard-delete migrated data. // and its legacy provenance. Never hard-delete migrated data.
archivedAt DateTime? archivedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
legacyRefs CustomerLegacyRef[] legacyRefs CustomerLegacyRef[]
properties Property[] properties Property[]
@@ -122,6 +122,7 @@ model Customer {
transactions Transaction[] transactions Transaction[]
statementDocuments StatementDocument[] statementDocuments StatementDocument[]
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
@@map("customers") @@map("customers")
} }
@@ -166,10 +167,10 @@ model PolicyType {
/// one table with a policyType discriminator, instead of one Access table /// one table with a policyType discriminator, instead of one Access table
/// per line of business. /// per line of business.
model Policy { model Policy {
id String @id @default(uuid()) id String @id @default(uuid())
policyNumber String policyNumber String
customerId String customerId String
customer Customer @relation(fields: [customerId], references: [id]) customer Customer @relation(fields: [customerId], references: [id])
policyTypeId String? policyTypeId String?
policyType PolicyType? @relation(fields: [policyTypeId], references: [id]) policyType PolicyType? @relation(fields: [policyTypeId], references: [id])
insuranceProviderId String? insuranceProviderId String?
@@ -178,18 +179,18 @@ model Policy {
policyDate DateTime? policyDate DateTime?
policyFrom DateTime? policyFrom DateTime?
policyTo DateTime? policyTo DateTime?
coveragePeriodDays Int? @default(365) coveragePeriodDays Int? @default(365)
netPremium Decimal? @db.Decimal(12, 2) netPremium Decimal? @db.Decimal(12, 2)
policyFee Decimal? @db.Decimal(12, 2) policyFee Decimal? @db.Decimal(12, 2)
brokerFee Decimal? @db.Decimal(12, 2) brokerFee Decimal? @db.Decimal(12, 2)
commission Decimal? @db.Decimal(12, 2) commission Decimal? @db.Decimal(12, 2)
total Decimal? @db.Decimal(12, 2) total Decimal? @db.Decimal(12, 2)
currency Currency @default(MXN) currency Currency @default(MXN)
observations String? @db.Text observations String? @db.Text
notes String? @db.Text notes String? @db.Text
coveragesJson Json? coveragesJson Json?
endorsement Boolean @default(false) endorsement Boolean @default(false)
liquidated Boolean @default(false) liquidated Boolean @default(false)
liquidationNumber String? liquidationNumber String?
liquidationDate DateTime? liquidationDate DateTime?
// Soft-delete marker (see Customer.archivedAt). Never hard-delete migrated // Soft-delete marker (see Customer.archivedAt). Never hard-delete migrated
@@ -198,8 +199,8 @@ model Policy {
legacySourceDb String? legacySourceDb String?
legacySourceTable String? legacySourceTable String?
legacyId String? legacyId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
installments PolicyPaymentInstallment[] installments PolicyPaymentInstallment[]
vehicles Vehicle[] vehicles Vehicle[]
@@ -210,6 +211,9 @@ model Policy {
properties Property[] properties Property[]
renewalNotices RenewalNotice[] renewalNotices RenewalNotice[]
ocrMatchedDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentPolicy")
ocrCreatedDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCreatedPolicy")
@@unique([legacySourceDb, legacySourceTable, legacyId]) @@unique([legacySourceDb, legacySourceTable, legacyId])
@@index([policyNumber]) @@index([policyNumber])
@@map("policies") @@map("policies")
@@ -277,7 +281,7 @@ model Vehicle {
licensePlate String? licensePlate String?
vinNumber String? vinNumber String?
stateCode String? stateCode String?
notes String? @db.Text notes String? @db.Text
// One legacy policy row can carry up to 3 vehicles, so they share a // One legacy policy row can carry up to 3 vehicles, so they share a
// legacyId (the source row number) — provenance is NOT unique per vehicle. // legacyId (the source row number) — provenance is NOT unique per vehicle.
// Sync rebuilds legacy vehicles by scoped delete + reinsert instead of upsert. // Sync rebuilds legacy vehicles by scoped delete + reinsert instead of upsert.
@@ -304,9 +308,9 @@ model InsuredDriver {
/// From BENEF — already a clean child table in the source data. /// From BENEF — already a clean child table in the source data.
model PolicyBeneficiary { model PolicyBeneficiary {
id String @id @default(uuid()) id String @id @default(uuid())
policyId String policyId String
policy Policy @relation(fields: [policyId], references: [id]) policy Policy @relation(fields: [policyId], references: [id])
name String? name String?
address String? address String?
phone String? phone String?
@@ -317,21 +321,21 @@ model PolicyBeneficiary {
/// From DATOS (siniestros). /// From DATOS (siniestros).
model Claim { model Claim {
id String @id @default(uuid()) id String @id @default(uuid())
policyId String policyId String
policy Policy @relation(fields: [policyId], references: [id]) policy Policy @relation(fields: [policyId], references: [id])
claimType String? claimType String?
incidentDate DateTime? incidentDate DateTime?
reportedDate DateTime? reportedDate DateTime?
description String? @db.Text description String? @db.Text
adjusterId String? adjusterId String?
adjuster Adjuster? @relation(fields: [adjusterId], references: [id]) adjuster Adjuster? @relation(fields: [adjusterId], references: [id])
claimedAmount Decimal? @db.Decimal(12, 2) claimedAmount Decimal? @db.Decimal(12, 2)
settledAmount Decimal? @db.Decimal(12, 2) settledAmount Decimal? @db.Decimal(12, 2)
settlementDate DateTime? settlementDate DateTime?
checkNumber String? checkNumber String?
resolved Boolean @default(false) resolved Boolean @default(false)
resolutionNotes String? @db.Text resolutionNotes String? @db.Text
@@map("claims") @@map("claims")
} }
@@ -363,6 +367,115 @@ model PolicyDocument {
@@map("policy_documents") @@map("policy_documents")
} }
/// Insurance OCR intake (mirrors statement_batches / statement_documents for
/// the utility side). One upload session of policy PDFs from a provider
/// portal (GMX, etc.) — the parser proposes policyNumber → existing Policy
/// (or "new, pick customer"), staff confirms, and the system attaches the
/// source PDF and optionally writes a premium Transaction.
model PolicyOcrBatch {
id String @id @default(uuid())
/// Which insurance provider portal the batch came from. "GMX" today;
/// future providers (AXA, GNP, …) extend the parser, not this table.
provider String @default("GMX")
status PolicyOcrBatchStatus @default(UPLOADED)
uploadedById String
uploadedBy User @relation("PolicyOcrBatchUploader", fields: [uploadedById], references: [id])
label String?
fileCount Int @default(0)
/// Set when the pipeline fails as a whole (bad PDF, OCR binaries missing).
error String? @db.Text
createdAt DateTime @default(now())
completedAt DateTime?
documents PolicyOcrDocument[]
@@index([status, createdAt])
@@map("policy_ocr_batches")
}
enum PolicyOcrBatchStatus {
UPLOADED
PROCESSING
READY_FOR_REVIEW
COMPLETED
FAILED
}
/// One parsed policy page — one Policy → one Customer (after staff confirms).
model PolicyOcrDocument {
id String @id @default(uuid())
batchId String
batch PolicyOcrBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
pageNumber Int
/// The rendered page image in object storage. Source PDF kept too on the
/// batch (statement pattern) so re-running a corrected parser is possible.
storageKey String
status PolicyOcrDocumentStatus @default(PENDING_OCR)
ocrRawText String? @db.Text
ocrConfidence Decimal? @db.Decimal(4, 3)
/// Which parser claimed the page ("GMX" today).
provider String?
// Extracted header fields, all staff-editable in review.
extractedPolicyNumber String?
extractedInsuredName String?
extractedAdditionalInsured String?
extractedAgentName String?
extractedLegalAddress String? @db.Text
extractedZip String?
extractedPolicyFrom DateTime?
extractedPolicyTo DateTime?
extractedPolicyDate DateTime?
extractedCurrency String?
extractedNetPremium Decimal? @db.Decimal(12, 2)
extractedPolicyFee Decimal? @db.Decimal(12, 2)
extractedBrokerFee Decimal? @db.Decimal(12, 2)
extractedTotal Decimal? @db.Decimal(12, 2)
/// Per-coverage rows from the GMX "Material damages" / "Additional risk"
/// tables — preserved verbatim so a missing premium receipt still leaves
/// the coverages auditable.
extractedCoveragesJson Json?
extractedPremiumPayment String?
// Match by `Policy.policyNumber` → existing Policy / Customer.
matchedPolicyId String?
matchedPolicy Policy? @relation("PolicyOcrDocumentPolicy", fields: [matchedPolicyId], references: [id])
matchedCustomerId String?
matchedCustomer Customer? @relation("PolicyOcrDocumentCustomer", fields: [matchedCustomerId], references: [id])
/// All policies carrying the same number, with their customer. One is
/// normal; >1 means the policy number is shared across customers and a
/// human must pick.
matchCandidates Json?
matchNote String?
reviewedById String?
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
reviewedAt DateTime?
createdPolicyId String? @unique
createdPolicy Policy? @relation("PolicyOcrDocumentCreatedPolicy", fields: [createdPolicyId], references: [id])
postedTransactionId String? @unique
postedTransaction Transaction? @relation("PolicyOcrDocumentTransaction", fields: [postedTransactionId], references: [id])
createdAt DateTime @default(now())
@@unique([batchId, pageNumber])
@@index([status])
@@index([matchedCustomerId])
@@map("policy_ocr_documents")
}
enum PolicyOcrDocumentStatus {
PENDING_OCR
OCR_FAILED
NEEDS_REVIEW
MATCHED
CONFIRMED
POSTED
REJECTED
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Utilities domain // Utilities domain
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -370,11 +483,11 @@ model PolicyDocument {
/// From DATMEX — shared with the insurance domain so a property can carry /// From DATMEX — shared with the insurance domain so a property can carry
/// both a home-insurance policy and utility service enrollments. /// both a home-insurance policy and utility service enrollments.
model Property { model Property {
id String @id @default(uuid()) id String @id @default(uuid())
customerId String customerId String
customer Customer @relation(fields: [customerId], references: [id]) customer Customer @relation(fields: [customerId], references: [id])
policyId String? policyId String?
policy Policy? @relation(fields: [policyId], references: [id]) policy Policy? @relation(fields: [policyId], references: [id])
addressLine1 String? addressLine1 String?
addressLine2 String? addressLine2 String?
phone1 String? phone1 String?
@@ -393,15 +506,14 @@ model Property {
archivedAt DateTime? archivedAt DateTime?
legacySourceTable String? legacySourceTable String?
legacyId String? legacyId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
services PropertyService[] services PropertyService[]
documents ServiceDocument[] documents ServiceDocument[]
trustAccount TrustAccount? trustAccount TrustAccount?
@@index([cadastralKey])
@@unique([legacySourceTable, legacyId]) @@unique([legacySourceTable, legacyId])
@@index([cadastralKey])
@@map("properties") @@map("properties")
} }
@@ -417,7 +529,7 @@ model PropertyService {
route String? route String?
dueDay String? dueDay String?
active Boolean @default(true) active Boolean @default(true)
notes String? @db.Text notes String? @db.Text
statementDocuments StatementDocument[] statementDocuments StatementDocument[]
@@ -502,15 +614,15 @@ model StatementBatch {
/// One statement — one customer, one period — after splitting the batch. /// One statement — one customer, one period — after splitting the batch.
model StatementDocument { model StatementDocument {
id String @id @default(uuid()) id String @id @default(uuid())
batchId String batchId String
batch StatementBatch @relation(fields: [batchId], references: [id], onDelete: Cascade) batch StatementBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
/// 1-based page of the source PDF this was split from. /// 1-based page of the source PDF this was split from.
pageNumber Int pageNumber Int
/// The rendered page image in object storage. The source PDF is kept too, so /// The rendered page image in object storage. The source PDF is kept too, so
/// a reviewer can always see exactly what the parser read. /// a reviewer can always see exactly what the parser read.
storageKey String storageKey String
status StatementDocumentStatus @default(PENDING_OCR) status StatementDocumentStatus @default(PENDING_OCR)
/// Raw OCR text, kept even after a manual correction so a mismatch between /// Raw OCR text, kept even after a manual correction so a mismatch between
/// what the machine read and what staff entered stays auditable. /// what the machine read and what staff entered stays auditable.
@@ -522,10 +634,10 @@ model StatementDocument {
// Extracted, then staff-corrected in place. `extractedAccountRef` is already // Extracted, then staff-corrected in place. `extractedAccountRef` is already
// normalised for matching (CFE leading zeros stripped, Telnor LADA removed). // normalised for matching (CFE leading zeros stripped, Telnor LADA removed).
extractedAccountRef String? extractedAccountRef String?
extractedAmount Decimal? @db.Decimal(12, 2) extractedAmount Decimal? @db.Decimal(12, 2)
extractedPeriod String? extractedPeriod String?
extractedDueDate DateTime? extractedDueDate DateTime?
/// Clave catastral when the statement prints one — a second key to match on /// Clave catastral when the statement prints one — a second key to match on
/// when the account number is unreadable. /// when the account number is unreadable.
extractedCadastralKey String? extractedCadastralKey String?
@@ -586,21 +698,21 @@ model TypeTransaction {
/// Unifies utilities' EFECTIVO/EFECTIVO FM3/EFECTIVO_BACKUP/FEE ANUAL/ /// Unifies utilities' EFECTIVO/EFECTIVO FM3/EFECTIVO_BACKUP/FEE ANUAL/
/// datos2/fee15/billing/CHEQUE FM3/IVA 2015 and insurance's EFECTIVO. /// datos2/fee15/billing/CHEQUE FM3/IVA 2015 and insurance's EFECTIVO.
model Transaction { model Transaction {
id String @id @default(uuid()) id String @id @default(uuid())
customerId String customerId String
customer Customer @relation(fields: [customerId], references: [id]) customer Customer @relation(fields: [customerId], references: [id])
domain TransactionDomain domain TransactionDomain
typeId String? typeId String?
type TypeTransaction? @relation(fields: [typeId], references: [id]) type TypeTransaction? @relation(fields: [typeId], references: [id])
transactionDate DateTime transactionDate DateTime
period String? period String?
reference String? reference String?
amount Decimal @db.Decimal(12, 2) amount Decimal @db.Decimal(12, 2)
currency Currency @default(MXN) currency Currency @default(MXN)
exchangeRate Decimal? @db.Decimal(10, 4) exchangeRate Decimal? @db.Decimal(10, 4)
checkNumber String? checkNumber String?
message String? @db.Text message String? @db.Text
outstanding Boolean @default(false) outstanding Boolean @default(false)
/// How this row was captured. NULL = migrated from Access (the legacy* /// How this row was captured. NULL = migrated from Access (the legacy*
/// columns below say which table). Set explicitly on everything the app /// columns below say which table). Set explicitly on everything the app
/// books, so an OCR-posted receipt is distinguishable from a hand-keyed one /// books, so an OCR-posted receipt is distinguishable from a hand-keyed one
@@ -620,25 +732,26 @@ model Transaction {
legacySourceDb String? legacySourceDb String?
legacySourceTable String? legacySourceTable String?
legacyId String? legacyId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
/// Set only on OCR-posted rows — the statement page this came from. /// Set only on OCR-posted rows — the statement page this came from.
statementDocument StatementDocument? statementDocument StatementDocument?
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
@@unique([legacySourceDb, legacySourceTable, legacyId])
@@index([customerId, transactionDate]) @@index([customerId, transactionDate])
// By-check reconciliation (billing.byCheck / the cheque-count report) looks // By-check reconciliation (billing.byCheck / the cheque-count report) looks
// rows up by check number alone — the legacy EDITA CHEQUE COUNT lookup. // rows up by check number alone — the legacy EDITA CHEQUE COUNT lookup.
@@index([checkNumber]) @@index([checkNumber])
// Drives the duplicate-post guard in BillingService.createBatch. // Drives the duplicate-post guard in BillingService.createBatch.
@@index([captureRef]) @@index([captureRef])
@@unique([legacySourceDb, legacySourceTable, legacyId])
@@map("transactions") @@map("transactions")
} }
/// From TIPO HIST. /// From TIPO HIST.
model ExchangeRate { model ExchangeRate {
id String @id @default(uuid()) id String @id @default(uuid())
rate Decimal @db.Decimal(10, 4) rate Decimal @db.Decimal(10, 4)
effectiveDate DateTime effectiveDate DateTime
effectiveHour DateTime? effectiveHour DateTime?
@@ -708,7 +821,7 @@ model BankTransaction {
category BusinessLineCategory? @relation(fields: [categoryId], references: [id]) category BusinessLineCategory? @relation(fields: [categoryId], references: [id])
cleared Boolean @default(false) cleared Boolean @default(false)
transferred Boolean @default(false) transferred Boolean @default(false)
notes String? @db.Text notes String? @db.Text
amountInWords String? amountInWords String?
// Append + void (see Transaction.voidedAt): excluded from income/expense/net. // Append + void (see Transaction.voidedAt): excluded from income/expense/net.
voidedAt DateTime? voidedAt DateTime?
@@ -745,6 +858,9 @@ model User {
statementBatches StatementBatch[] @relation("StatementBatchUploader") statementBatches StatementBatch[] @relation("StatementBatchUploader")
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer") statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader")
policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer")
@@map("users") @@map("users")
} }
@@ -773,7 +889,7 @@ model EmailCampaign {
id String @id @default(uuid()) id String @id @default(uuid())
campaignName String campaignName String
subject String? subject String?
body String? @db.Text body String? @db.Text
status String @default("in_progress") status String @default("in_progress")
emailSentCount Int @default(0) emailSentCount Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -786,8 +902,8 @@ model EmailLog {
customerId String? customerId String?
emailAddress String? emailAddress String?
emailType String? emailType String?
requestBody String? @db.Text requestBody String? @db.Text
responseBody String? @db.Text responseBody String? @db.Text
sentAt DateTime @default(now()) sentAt DateTime @default(now())
@@map("email_log") @@map("email_log")
@@ -813,16 +929,16 @@ enum OpsJobStatus {
} }
model OpsJob { model OpsJob {
id String @id @default(uuid()) id String @id @default(uuid())
kind OpsJobKind kind OpsJobKind
status OpsJobStatus @default(RUNNING) status OpsJobStatus @default(RUNNING)
// Combined stdout+stderr of the spawned process, appended as it runs. // Combined stdout+stderr of the spawned process, appended as it runs.
log String @db.LongText log String @db.LongText
// Op-specific inputs (e.g. the backup filename a RESTORE targets). No FK on // Op-specific inputs (e.g. the backup filename a RESTORE targets). No FK on
// createdById — the actor id is stored flat, like activity_logs' userId use. // createdById — the actor id is stored flat, like activity_logs' userId use.
params Json? params Json?
createdById String? createdById String?
startedAt DateTime @default(now()) startedAt DateTime @default(now())
finishedAt DateTime? finishedAt DateTime?
@@index([status]) @@index([status])