Files
jorgecuadros-platform/apps/api/src/policy-ocr/policy-ocr.service.ts
T
rmancinas 3125b52057 feat(ocr): discard abandoned capture batches
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
2026-08-02 02:00:02 -07:00

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