feat(policy-ocr): suggest the customer from the printed insured name
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>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { PolicyMatcherService } from "./policy-matcher.service";
|
||||
import type { PrismaService } from "../prisma/prisma.service";
|
||||
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||
|
||||
function parsed(over: Partial<ParsedPolicy> = {}): ParsedPolicy {
|
||||
return {
|
||||
provider: "GMX",
|
||||
policyNumber: null,
|
||||
insuredName: null,
|
||||
notes: [],
|
||||
coverages: [],
|
||||
vehicles: [],
|
||||
drivers: [],
|
||||
...over,
|
||||
} as unknown as ParsedPolicy;
|
||||
}
|
||||
|
||||
function prismaStub(policies: unknown[], customers: { id: string; name: string }[]) {
|
||||
const findManyPolicy = jest.fn().mockResolvedValue(policies);
|
||||
const findManyCustomer = jest.fn().mockResolvedValue(customers);
|
||||
return {
|
||||
prisma: {
|
||||
policy: { findMany: findManyPolicy },
|
||||
customer: { findMany: findManyCustomer },
|
||||
} as unknown as PrismaService,
|
||||
findManyPolicy,
|
||||
findManyCustomer,
|
||||
};
|
||||
}
|
||||
|
||||
const BOOK = [
|
||||
{ id: "cust-1", name: "WAGONER, PAMELA" },
|
||||
{ id: "cust-2", name: "SMITH, JOHN" },
|
||||
];
|
||||
|
||||
describe("PolicyMatcherService name suggestions", () => {
|
||||
it("suggests a customer when the policy number is new", async () => {
|
||||
const { prisma } = prismaStub([], BOOK);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
const r = await svc.match(
|
||||
parsed({ policyNumber: "P-999", insuredName: "PAMELA DENISE WAGONER" } as never),
|
||||
);
|
||||
|
||||
expect(r.customerSuggestions).toEqual([
|
||||
expect.objectContaining({ customerId: "cust-1", tier: "PARTIAL" }),
|
||||
]);
|
||||
// The suggestion is surfaced, never applied.
|
||||
expect(r.customerId).toBeNull();
|
||||
expect(r.confident).toBe(false);
|
||||
expect(r.note).toContain("posibles clientes por nombre: WAGONER, PAMELA");
|
||||
});
|
||||
|
||||
it("suggests when the policy number could not be read at all", async () => {
|
||||
const { prisma } = prismaStub([], BOOK);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
const r = await svc.match(parsed({ insuredName: "PAMELA WAGONER" } as never));
|
||||
|
||||
expect(r.customerSuggestions[0]).toMatchObject({ customerId: "cust-1", tier: "EXACT" });
|
||||
expect(r.customerId).toBeNull();
|
||||
expect(r.note).toBe(
|
||||
"no se pudo leer el número de póliza; posible cliente por nombre: WAGONER, PAMELA",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch the book when the policy number hits", async () => {
|
||||
const { prisma, findManyCustomer } = prismaStub(
|
||||
[
|
||||
{
|
||||
id: "pol-1",
|
||||
policyNumber: "P-1",
|
||||
customerId: "cust-2",
|
||||
customer: { name: "SMITH, JOHN" },
|
||||
},
|
||||
],
|
||||
BOOK,
|
||||
);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
const r = await svc.match(
|
||||
parsed({ policyNumber: "P-1", insuredName: "PAMELA WAGONER" } as never),
|
||||
);
|
||||
|
||||
expect(r.confident).toBe(true);
|
||||
expect(r.customerId).toBe("cust-2");
|
||||
expect(r.customerSuggestions).toEqual([]);
|
||||
expect(findManyCustomer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the customer book once across a batch", async () => {
|
||||
const { prisma, findManyCustomer } = prismaStub([], BOOK);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
await svc.match(parsed({ policyNumber: "A", insuredName: "PAMELA WAGONER" } as never));
|
||||
await svc.match(parsed({ policyNumber: "B", insuredName: "JOHN SMITH" } as never));
|
||||
|
||||
expect(findManyCustomer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user