The office books customers surname-first ("WAGONER, PAMELA") and carriers
print them given-name-first ("PAMELA DENISE WAGONER"), so the review screen
made staff retype a name the parser had already read. Comparing normalized
token sets makes the two orderings the same thing.
Only on the zero-hit path, where the policy number found nothing and a human
has to pick a customer anyway. The suggestions are written to a new
`customerSuggestions` column rather than `matchCandidates`, which the review
screen reads as policy-number hits, and they never set `matchedCustomerId` or
`confident` — matching on `Policy.policyNumber` is unchanged.
Two tiers, drawn where the real book has cliffs: EXACT (identical token sets)
and PARTIAL (containment, >=2 shared tokens, surname present). Of 1536
customers, 1487 have a distinct token set, so EXACT cross-person collisions
are ~0; loosen to surname + first given name and 131 (8.5%) collide, and 185
surnames are shared by 524 customers, which is why one token is never enough
and the surname must be printed explicitly. Replaying every book row as a
carrier would print it: 97.9% top-ranked correct, 1.2% a different row, all
but two of those the same human on a duplicate or variant row.
Normalization folds accents (OCR's MUNOZ reaches the book's MUÑOZ), drops
initials, Spanish particles, JR/S.A. DE C.V., and any token with a digit —
ANA prints the phone hard against the name as `Ph.3102001538`. Names over 8
tokens or 80 characters are refused outright, because GMX's especificación
has no field labels and the parser has handed its whole first page over as
`insuredName`.
Not used for utility statements: there the registrant genuinely is not the
customer, so the same trick would be wrong rather than noisy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
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<MatchResult> {
|
|
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<CustomerNameSuggestion[]> {
|
|
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<CustomerNameRow[]> {
|
|
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,
|
|
};
|
|
}
|
|
} |