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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user