feat(polizas): OCR capture for insurance policy PDFs
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:
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user