A bad scan, the wrong PDFs or a duplicate upload used to leave a batch sitting in READY_FOR_REVIEW forever, because the only exits were confirm (posts to the books) or rejecting every page one at a time. Add a DISCARDED terminal status to both OCR domains and a single endpoint per domain that rejects every page still pending in one shot. Discarding is refused once anything has landed: statements once a page is POSTED, policies once a page is APPLIED. Those batches did real work and have to be settled page by page. - POST /statements/batches/:id/discard - POST /policy-ocr/batches/:id/discard - shared DiscardBatchCard on both review screens, gated the same way
767 lines
30 KiB
TypeScript
767 lines
30 KiB
TypeScript
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.");
|
|
}
|
|
const updated = await this.prisma.policyOcrDocument.update({
|
|
where: { id },
|
|
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
|
});
|
|
// Rejecting the last open page settles the batch just as confirming it
|
|
// would — without this, a fully-rejected batch sat in READY_FOR_REVIEW
|
|
// forever because only confirmBatch() ever closed one.
|
|
await this.closeIfDone(doc.batchId);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Throw away a whole batch that is pending review: every page that has not
|
|
* been applied is marked REJECTED and the batch itself becomes DISCARDED.
|
|
*
|
|
* Refuses once any page is POSTED — a partly-applied batch has already
|
|
* written Policy (and possibly Transaction) rows, and hiding the paperwork
|
|
* behind a "discarded" label would leave those rows unexplained. Reject the
|
|
* remaining pages individually instead.
|
|
*/
|
|
async discardBatch(batchId: string, reviewedById: string) {
|
|
const batch = await this.prisma.policyOcrBatch.findUnique({
|
|
where: { id: batchId },
|
|
});
|
|
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
|
if (batch.status === "DISCARDED") {
|
|
throw new BadRequestException("Este lote ya fue descartado.");
|
|
}
|
|
|
|
const posted = await this.prisma.policyOcrDocument.count({
|
|
where: { batchId, status: "POSTED" },
|
|
});
|
|
if (posted > 0) {
|
|
throw new BadRequestException(
|
|
`No se puede descartar: ${posted} página(s) ya se aplicaron a una póliza.`,
|
|
);
|
|
}
|
|
|
|
const { count } = await this.prisma.policyOcrDocument.updateMany({
|
|
where: { batchId, status: { notIn: ["POSTED", "REJECTED"] } },
|
|
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
|
});
|
|
|
|
await this.prisma.policyOcrBatch.update({
|
|
where: { id: batchId },
|
|
data: { status: "DISCARDED", completedAt: new Date() },
|
|
});
|
|
|
|
return { batchId, rejected: count };
|
|
}
|
|
|
|
// --- 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.updateMany({
|
|
// `updateMany` + a status filter so a discarded batch is never quietly
|
|
// relabelled COMPLETED by a late reject on one of its pages.
|
|
where: { id: batchId, status: { not: "DISCARDED" } },
|
|
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;
|
|
} |