import { Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import type { ParsedPolicy } from "./parsers/policy-parser"; import { suggestCustomersByName, suggestionNote, type CustomerNameRow, type CustomerNameSuggestion, } from "./name-matcher"; 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 }[]; /** * Customers whose name resembles the printed insured name. Populated only * when the policy number resolved to nothing, and never used to set * `customerId` or `confident` — see the class comment. */ customerSuggestions: CustomerNameSuggestion[]; } /** * How long the customer book is reused across documents in a batch. * * A twenty-page batch would otherwise read all 1536 rows twenty times. The * only cost of the staleness is that a customer created in the last minute * is not suggested — the picker still finds them, so nothing is lost that a * reviewer cannot do in one click. */ const BOOK_TTL_MS = 60_000; /** * 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. * * On that zero-hit path only, the printed name is used to *rank the picker* * — see `name-matcher.ts`. That is not a walk-back of the rule above: the * suggestion never reaches `customerId` or `confident`, a human still picks, * and the ranking exists because the office writes names surname-first * ("WAGONER, PAMELA") while carriers print them given-name-first ("PAMELA * DENISE WAGONER"), so the reviewer is retyping a name the machine could * have offered. */ @Injectable() export class PolicyMatcherService { private book: { rows: CustomerNameRow[]; loadedAt: number } | null = null; constructor(private readonly prisma: PrismaService) {} async match(parsed: ParsedPolicy): Promise { if (!parsed.policyNumber) { // No number to search on, so the page goes to review with a picker — // the same place the name suggestions help. return this.unmatched( "no se pudo leer el número de póliza", await this.suggestByName(parsed.insuredName), ); } 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) { const suggestions = await this.suggestByName(parsed.insuredName); const hint = suggestionNote(suggestions); return { policyId: null, customerId: null, note: [ `no se encontró ninguna póliza con el número ${parsed.policyNumber}`, hint, ] .filter(Boolean) .join("; "), confident: false, candidates: [], customerSuggestions: suggestions, }; } if (rows.length > 1) { // The policy number did find rows; the reviewer picks among those, and // adding name guesses on top would only add noise. return { policyId: null, customerId: null, note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`, confident: false, candidates, customerSuggestions: [], }; } return { policyId: candidates[0].policyId, customerId: candidates[0].customerId, note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`, confident: true, candidates, customerSuggestions: [], }; } private async suggestByName( insuredName: string | null | undefined, ): Promise { if (!insuredName) return []; return suggestCustomersByName(insuredName, await this.customerBook()); } /** * The whole customer book, held briefly. 1536 rows of `{id, name}` is a * few hundred kilobytes and the comparison is pure token-set work, so * scanning it beats any SQL approximation — and a `LIKE` search would in * any case have to guess which token is the surname, which is the one * thing the office's own data does not agree on. */ private async customerBook(): Promise { if (this.book && Date.now() - this.book.loadedAt < BOOK_TTL_MS) { return this.book.rows; } const rows = await this.prisma.customer.findMany({ select: { id: true, name: true }, }); this.book = { rows, loadedAt: Date.now() }; return rows; } private unmatched( note: string, customerSuggestions: CustomerNameSuggestion[] = [], ): MatchResult { const hint = suggestionNote(customerSuggestions); return { policyId: null, customerId: null, note: [note, hint].filter(Boolean).join("; "), confident: false, candidates: [], customerSuggestions, }; } }