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 { 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 { 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 { 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; }