diff --git a/apps/api/src/policy-ocr/name-matcher.spec.ts b/apps/api/src/policy-ocr/name-matcher.spec.ts new file mode 100644 index 0000000..c73fcd0 --- /dev/null +++ b/apps/api/src/policy-ocr/name-matcher.spec.ts @@ -0,0 +1,161 @@ +import { + nameTokens, + suggestCustomersByName, + suggestionNote, + type CustomerNameRow, +} from "./name-matcher"; + +/** + * Every row here is a real name out of the customer book (1536 rows, dev + * mirror of production), chosen because it is one of the shapes that breaks + * naive matching: surname-first ordering, a middle initial, a Spanish double + * surname, a joint account, a missing comma, and the `(SIN NOMBRE)` + * placeholder the migration left for customers whose DATGRAL row had no name. + */ +const BOOK: CustomerNameRow[] = [ + { id: "c1", name: "WAGONER, PAMELA" }, + { id: "c2", name: "MCWILLIAMS, BRIAN MICHAEL" }, + { id: "c3", name: "MCWILLIAMS, BRIAN" }, + { id: "c4", name: "WEAKLAND, RICHARD E." }, + { id: "c5", name: "ESTRADA, JERRY & MARILYN" }, + { id: "c6", name: "CABALLERO PRIETO, GUILLERMO" }, + { id: "c7", name: "GREENE STEPHANIE" }, + { id: "c8", name: "(SIN NOMBRE)" }, + { id: "c9", name: "MUÑOZ, LUIS ALBERTO" }, + { id: "c10", name: "SMITH, DANIEL" }, + { id: "c11", name: "SMITH, JOHN" }, +]; + +describe("nameTokens", () => { + it("makes the two orderings the same set", () => { + expect(nameTokens("PAMELA WAGONER").sort()).toEqual( + nameTokens("WAGONER, PAMELA").sort(), + ); + }); + + it("drops initials, particles and corporate suffixes", () => { + expect(nameTokens("WEAKLAND, RICHARD E.")).toEqual(["WEAKLAND", "RICHARD"]); + expect(nameTokens("GARCIA DE LA TORRE, ANA")).toEqual(["GARCIA", "TORRE", "ANA"]); + expect(nameTokens("CONSTRUCTORA BAJA S.A. DE C.V.")).toEqual([ + "CONSTRUCTORA", + "BAJA", + ]); + }); + + it("folds accents so OCR's MUNOZ reaches the book's MUÑOZ", () => { + expect(nameTokens("MUÑOZ")).toEqual(["MUNOZ"]); + }); + + it("drops the phone number ANA prints against the insured name", () => { + // Observed verbatim from the ANA automobile face. + expect(nameTokens("MARIA GARCIA Ph.3102001538")).toEqual([ + "MARIA", + "GARCIA", + "PH", + ]); + }); +}); + +describe("suggestCustomersByName", () => { + it("matches the reversed name exactly", () => { + const [top] = suggestCustomersByName("PAMELA WAGONER", BOOK); + expect(top).toMatchObject({ customerId: "c1", tier: "EXACT", score: 1 }); + }); + + it("treats a printed middle name the book lacks as a partial hit", () => { + const hits = suggestCustomersByName("PAMELA DENISE WAGONER", BOOK); + expect(hits[0]).toMatchObject({ customerId: "c1", tier: "PARTIAL" }); + expect(hits[0].score).toBeCloseTo(2 / 3); + }); + + it("ranks the exact row above the row that merely contains it", () => { + // Both MCWILLIAMS rows are reachable from this name; the one that holds + // the middle name is the exact set and must come first. + const hits = suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK); + expect(hits.map((h) => h.customerId)).toEqual(["c2", "c3"]); + expect(hits[0].tier).toBe("EXACT"); + expect(hits[1].tier).toBe("PARTIAL"); + }); + + it("reaches a joint account from the one spouse the carrier printed", () => { + const hits = suggestCustomersByName("JERRY ESTRADA", BOOK); + expect(hits[0]).toMatchObject({ customerId: "c5", tier: "PARTIAL" }); + }); + + it("will not reach a joint account on given names alone", () => { + // No surname printed: `JERRY MARILYN` overlaps ESTRADA, JERRY & MARILYN + // on two tokens, and matching on that would book a stranger's policy. + expect(suggestCustomersByName("JERRY MARILYN", BOOK)).toEqual([]); + }); + + it("matches a Spanish double surname regardless of where the comma fell", () => { + const [top] = suggestCustomersByName("GUILLERMO CABALLERO PRIETO", BOOK); + expect(top).toMatchObject({ customerId: "c6", tier: "EXACT" }); + }); + + it("still matches a book row that has no comma", () => { + const [top] = suggestCustomersByName("STEPHANIE GREENE", BOOK); + expect(top).toMatchObject({ customerId: "c7", tier: "EXACT" }); + }); + + it("never suggests the (SIN NOMBRE) placeholder", () => { + expect(suggestCustomersByName("SIN NOMBRE", BOOK)).toEqual([]); + expect(suggestCustomersByName("NOMBRE DEL ASEGURADO", BOOK)).toEqual([]); + }); + + it("returns nothing on a shared surname alone", () => { + // 185 surnames are shared by 524 customers; one token is not evidence. + expect(suggestCustomersByName("SMITH", BOOK)).toEqual([]); + }); + + it("returns nothing for a different person with the same surname", () => { + expect(suggestCustomersByName("ROBERT SMITH", BOOK)).toEqual([]); + }); + + it("refuses a page-sized blob", () => { + // GMX's especificación has no field labels and the parser has handed its + // whole first page over as the insured name. + const blob = + "ESPECIFICACION DE LA POLIZA DE SEGURO DE RESPONSABILIDAD CIVIL " + + "EXPEDIDA A FAVOR DE PAMELA WAGONER CON VIGENCIA DEL 01 DE ENERO"; + expect(suggestCustomersByName(blob, BOOK)).toEqual([]); + }); + + it("caps the list", () => { + expect(suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK, 1)).toHaveLength(1); + }); + + it("handles a null insured name", () => { + expect(suggestCustomersByName(null, BOOK)).toEqual([]); + }); +}); + +describe("suggestionNote", () => { + it("says nothing when there is nothing", () => { + expect(suggestionNote([])).toBeNull(); + }); + + it("names a single exact hit", () => { + expect(suggestionNote(suggestCustomersByName("PAMELA WAGONER", BOOK))).toBe( + "posible cliente por nombre: WAGONER, PAMELA", + ); + }); + + it("reports a tie rather than picking one", () => { + // The book really does hold EMERY, LAURA twice and KIRCHHOFF, CINDY + // three times. + const dupes: CustomerNameRow[] = [ + { id: "d1", name: "EMERY, LAURA" }, + { id: "d2", name: "EMERY, LAURA" }, + ]; + expect(suggestionNote(suggestCustomersByName("LAURA EMERY", dupes))).toBe( + "2 clientes tienen ese mismo nombre; elija cuál", + ); + }); + + it("lists partial hits", () => { + expect(suggestionNote(suggestCustomersByName("PAMELA DENISE WAGONER", BOOK))).toBe( + "posibles clientes por nombre: WAGONER, PAMELA", + ); + }); +}); diff --git a/apps/api/src/policy-ocr/name-matcher.ts b/apps/api/src/policy-ocr/name-matcher.ts new file mode 100644 index 0000000..c850f8a --- /dev/null +++ b/apps/api/src/policy-ocr/name-matcher.ts @@ -0,0 +1,199 @@ +/** + * Suggests which existing customer a printed insured name belongs to. + * + * The office books customers surname-first ("WAGONER, PAMELA") and carriers + * print them given-name-first ("PAMELA DENISE WAGONER"), so a string compare + * never hits. Comparing *token sets* does, and it is order-insensitive by + * construction — which is the whole trick. + * + * **These are suggestions, never matches.** Nothing here sets + * `matchedCustomerId` or `confident`; the review screen offers the ranked + * names and a human picks. That line is not caution, it is what the book + * measures out to: of 1536 customers, 1487 have a distinct normalized token + * set — but loosen the rule to surname + first given name only and 131 of + * them (8.5%) collide, because the book holds `MCWILLIAMS, BRIAN MICHAEL` + * *and* `MCWILLIAMS, BRIAN`, and `CUADROS, JORGE JR` alongside three + * `CUADROS, JORGE H.`. 185 surnames are shared by 524 customers, so a + * surname alone carries no information at all. + * + * The two tiers below are drawn at the two places that measurement puts a + * cliff: full token-set equality, where cross-person collisions are + * effectively zero, and strict containment, where they are common enough + * that the result can only ever be a hint. + */ + +/** A customer row as the matcher needs it — id and the book's name. */ +export interface CustomerNameRow { + id: string; + name: string; +} + +export type NameMatchTier = "EXACT" | "PARTIAL"; + +export interface CustomerNameSuggestion { + customerId: string; + customerName: string; + /** + * `EXACT` — the two names carry the same tokens, in any order. + * `PARTIAL` — one name's tokens are all present in the other's, plus the + * surname. A printed middle name the book does not hold, or a joint + * account where the carrier named one spouse, both land here. + */ + tier: NameMatchTier; + /** Shared tokens over the longer name's token count, 0..1. */ + score: number; +} + +/** + * Words that carry no identity. Spanish particles and the ampersand joining + * a couple are noise; the corporate suffixes are dropped so `S.A. DE C.V.` + * does not make every company look alike. + */ +const NOISE = new Set([ + "DE", "DEL", "LA", "LAS", "LOS", "Y", "AND", "VDA", + "JR", "SR", "II", "III", "IV", + "SA", "CV", "SAPI", "SRL", "RL", "SC", "INC", "LLC", "LTD", "CORP", "CO", +]); + +/** + * Placeholder rows the migration left behind. Fourteen customers are named + * literally `(SIN NOMBRE)`; without this they would be one 14-way tie on + * every unreadable name. + */ +const PLACEHOLDER = new Set(["SIN NOMBRE", "NOMBRE SIN"]); + +/** + * A name blob longer than this is not a name. GMX's PVL especificación has + * no field labels, and the parser has been seen handing its entire first + * page over as `insuredName`; matching that against the book would find + * a surname somewhere in the prose and suggest a stranger. + */ +const MAX_TOKENS = 8; +const MAX_CHARS = 80; + +/** + * Splits a name into comparable tokens. + * + * Accents go first, and deliberately in both directions: the book holds + * `MUÑOZ` where OCR routinely reads `MUNOZ`, and folding both to the same + * ASCII makes that a hit rather than a miss. + * + * Tokens containing digits are dropped outright. ANA's automobile face + * prints the phone number hard against the insured name — the parser has + * emitted `MARIA GARCIA Ph.3102001538` — and the digits would otherwise + * be an extra token forever blocking `EXACT`. + * + * Single letters are dropped as initials: the book is full of + * `WEAKLAND, RICHARD E.`, and a carrier that prints the middle name in + * full should still match the row that abbreviates it. + */ +export function nameTokens(raw: string): string[] { + const cleaned = raw + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toUpperCase() + .replace(/[^A-Z0-9]+/g, " ") + .trim(); + + const tokens = cleaned + .split(" ") + .filter((t) => t.length > 1 && !/\d/.test(t) && !NOISE.has(t)); + + return [...new Set(tokens)]; +} + +/** The surname tokens — everything before the comma the book writes. */ +function surnameTokens(bookName: string): string[] { + const comma = bookName.indexOf(","); + // 54 of 1536 rows have no comma at all ("GREENE STEPHANIE", + // "FAROOQ VAKIL"), and which half is the surname is unknowable. Requiring + // a surname we cannot identify would silently exclude those rows, so they + // fall back to requiring nothing beyond the containment rule. + if (comma < 0) return []; + return nameTokens(bookName.slice(0, comma)); +} + +function isPlaceholder(tokens: string[]): boolean { + return tokens.length === 0 || PLACEHOLDER.has([...tokens].sort().join(" ")); +} + +function containsAll(haystack: Set, needles: string[]): boolean { + return needles.every((n) => haystack.has(n)); +} + +/** + * Ranks the book against one printed name. + * + * Returns at most `limit` suggestions, `EXACT` before `PARTIAL` and higher + * score first. An empty array means the printed name was unusable (too + * long, too few real tokens) or nothing in the book came close — both of + * which leave the review screen exactly as it is today. + */ +export function suggestCustomersByName( + printedName: string | null | undefined, + customers: CustomerNameRow[], + limit = 3, +): CustomerNameSuggestion[] { + if (!printedName || printedName.length > MAX_CHARS) return []; + + const printed = nameTokens(printedName); + // One usable token is a surname or a given name on its own, and 34% of the + // book shares a surname with someone. Nothing useful can come of it. + if (printed.length < 2 || printed.length > MAX_TOKENS) return []; + + const printedSet = new Set(printed); + const out: CustomerNameSuggestion[] = []; + + for (const c of customers) { + const book = nameTokens(c.name); + if (isPlaceholder(book) || book.length < 2) continue; + + const bookSet = new Set(book); + const overlap = printed.filter((t) => bookSet.has(t)).length; + // Two shared tokens is the floor: one is a bare surname collision. + if (overlap < 2) continue; + + const bookInPrinted = containsAll(printedSet, book); + const printedInBook = containsAll(bookSet, printed); + if (!bookInPrinted && !printedInBook) continue; + + // When the book's name is the shorter one, containment already proves + // the surname was printed. When the printed name is shorter — the book + // holds a middle name or a second spouse the carrier omitted — the + // surname must be there explicitly, or `JERRY MARILYN` would match + // `ESTRADA, JERRY & MARILYN` on given names alone. + if (!bookInPrinted && !containsAll(printedSet, surnameTokens(c.name))) continue; + + out.push({ + customerId: c.id, + customerName: c.name, + tier: bookInPrinted && printedInBook ? "EXACT" : "PARTIAL", + score: overlap / Math.max(book.length, printed.length), + }); + } + + out.sort((a, b) => { + if (a.tier !== b.tier) return a.tier === "EXACT" ? -1 : 1; + if (b.score !== a.score) return b.score - a.score; + return a.customerName.localeCompare(b.customerName); + }); + + return out.slice(0, limit); +} + +/** Review-queue wording for what the suggestions amount to. */ +export function suggestionNote(suggestions: CustomerNameSuggestion[]): string | null { + if (suggestions.length === 0) return null; + + const exact = suggestions.filter((s) => s.tier === "EXACT"); + // More than one exact hit is the duplicate-customer case the book really + // has (`EMERY, LAURA` twice, `KIRCHHOFF, CINDY` three times). Saying so is + // more useful than naming whichever one sorted first. + if (exact.length > 1) { + return `${exact.length} clientes tienen ese mismo nombre; elija cuál`; + } + if (exact.length === 1) { + return `posible cliente por nombre: ${exact[0].customerName}`; + } + return `posibles clientes por nombre: ${suggestions.map((s) => s.customerName).join(", ")}`; +} diff --git a/apps/api/src/policy-ocr/policy-matcher.service.spec.ts b/apps/api/src/policy-ocr/policy-matcher.service.spec.ts new file mode 100644 index 0000000..21fce8b --- /dev/null +++ b/apps/api/src/policy-ocr/policy-matcher.service.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/api/src/policy-ocr/policy-matcher.service.ts b/apps/api/src/policy-ocr/policy-matcher.service.ts index cf59cf8..1efa456 100644 --- a/apps/api/src/policy-ocr/policy-matcher.service.ts +++ b/apps/api/src/policy-ocr/policy-matcher.service.ts @@ -1,6 +1,12 @@ 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; @@ -14,8 +20,24 @@ export interface MatchResult { * 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. @@ -33,14 +55,29 @@ export interface MatchResult { * 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) { - return this.unmatched("no se pudo leer el número de póliza"); + // 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({ @@ -61,22 +98,33 @@ export class PolicyMatcherService { })); 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}`, + 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: [], }; } @@ -86,16 +134,47 @@ export class PolicyMatcherService { note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`, confident: true, candidates, + customerSuggestions: [], }; } - private unmatched(note: string): MatchResult { + 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: [note, hint].filter(Boolean).join("; "), confident: false, candidates: [], + customerSuggestions, }; } } \ No newline at end of file diff --git a/apps/api/src/policy-ocr/policy-ocr.service.ts b/apps/api/src/policy-ocr/policy-ocr.service.ts index 1928f9a..d57648b 100644 --- a/apps/api/src/policy-ocr/policy-ocr.service.ts +++ b/apps/api/src/policy-ocr/policy-ocr.service.ts @@ -211,6 +211,9 @@ export class PolicyOcrService { matchCandidates: match.candidates.length ? (match.candidates as unknown as Prisma.InputJsonValue) : Prisma.DbNull, + customerSuggestions: match.customerSuggestions.length + ? (match.customerSuggestions as unknown as Prisma.InputJsonValue) + : Prisma.DbNull, matchNote: notes.join("; "), }, }); diff --git a/apps/web/src/components/PolicyOcrReview.tsx b/apps/web/src/components/PolicyOcrReview.tsx index 7bb2abf..7573a0e 100644 --- a/apps/web/src/components/PolicyOcrReview.tsx +++ b/apps/web/src/components/PolicyOcrReview.tsx @@ -21,6 +21,7 @@ import type { PolicyOcrBatchDetail, PolicyOcrConfirmDocument, PolicyOcrCoverage, + PolicyOcrCustomerSuggestion, PolicyOcrDocument, PolicyOcrReviewInput, } from "@/lib/types"; @@ -351,6 +352,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume const locked = doc.status === "POSTED" || doc.status === "REJECTED"; const matchedExisting = !!doc.matchedPolicy; const candidates = doc.matchCandidates ?? []; + const suggestions: PolicyOcrCustomerSuggestion[] = doc.customerSuggestions ?? []; return (
@@ -670,6 +672,34 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume )} + {/* + * Name suggestions, never a preselection. The office writes + * customers surname-first and carriers print them given-name-first, + * so without this the reviewer retypes a name the parser already + * read. One click fills the picker above; nothing is chosen until + * they click. Kept outside the label — a label must not + * wrap other interactive controls. + */} + {!policyId && !customerId && !locked && canReview && suggestions.length > 0 && ( +
+ Sugerencias por nombre: + {suggestions.map((s) => ( + + ))} +
+ )} +