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>
101 lines
3.2 KiB
TypeScript
101 lines
3.2 KiB
TypeScript
import { Injectable } from "@nestjs/common";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import type { ParsedPolicy } from "./parsers/policy-parser";
|
|
|
|
export interface MatchResult {
|
|
policyId: string | null;
|
|
customerId: string | null;
|
|
/** Why it landed here — shown in the review queue verbatim. */
|
|
note: string;
|
|
/** True only for an unambiguous hit on `Policy.policyNumber`. */
|
|
confident: boolean;
|
|
/**
|
|
* Every policy that carries the parsed number, with its customer. >1 means
|
|
* the policy number is shared across customers and a human must pick.
|
|
*/
|
|
candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[];
|
|
}
|
|
|
|
/**
|
|
* Resolves a parsed policy page to an existing Policy (and its customer) the
|
|
* office already holds.
|
|
*
|
|
* **Match on `Policy.policyNumber` alone, never on the printed insured name.**
|
|
* The certificate's "Insured" line is the account's registrant, which drifts
|
|
* from the current owner — the same problem the statement matcher cites for
|
|
* utility bills ("ARNAIZ ROSAS ELSA AURORA" on a CESPT receipt for a
|
|
* customer this office holds as "CATT, RANDY"). Names are surfaced for the
|
|
* reviewer to sanity-check and never feed matching.
|
|
*
|
|
* A policy number that matches zero rows means the policy is new: the
|
|
* review screen then offers a customer picker and the confirm step creates
|
|
* the row. Multiple hits are surfaced rather than auto-picked — duplicate
|
|
* policy numbers across customers do occur (same group policy bound by two
|
|
* related parties), and picking one arbitrarily would silently book the
|
|
* wrong coverage.
|
|
*/
|
|
@Injectable()
|
|
export class PolicyMatcherService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async match(parsed: ParsedPolicy): Promise<MatchResult> {
|
|
if (!parsed.policyNumber) {
|
|
return this.unmatched("no se pudo leer el número de póliza");
|
|
}
|
|
|
|
const rows = await this.prisma.policy.findMany({
|
|
where: { policyNumber: parsed.policyNumber },
|
|
select: {
|
|
id: true,
|
|
policyNumber: true,
|
|
customerId: true,
|
|
customer: { select: { name: true } },
|
|
},
|
|
});
|
|
|
|
const candidates = rows.map((r) => ({
|
|
policyId: r.id,
|
|
customerId: r.customerId,
|
|
customerName: r.customer.name,
|
|
policyNumber: r.policyNumber,
|
|
}));
|
|
|
|
if (rows.length === 0) {
|
|
return {
|
|
policyId: null,
|
|
customerId: null,
|
|
note: `no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
|
confident: false,
|
|
candidates: [],
|
|
};
|
|
}
|
|
|
|
if (rows.length > 1) {
|
|
return {
|
|
policyId: null,
|
|
customerId: null,
|
|
note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
|
|
confident: false,
|
|
candidates,
|
|
};
|
|
}
|
|
|
|
return {
|
|
policyId: candidates[0].policyId,
|
|
customerId: candidates[0].customerId,
|
|
note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
|
|
confident: true,
|
|
candidates,
|
|
};
|
|
}
|
|
|
|
private unmatched(note: string): MatchResult {
|
|
return {
|
|
policyId: null,
|
|
customerId: null,
|
|
note,
|
|
confident: false,
|
|
candidates: [],
|
|
};
|
|
}
|
|
} |