diff --git a/src/lib/item-size.test.ts b/src/lib/item-size.test.ts index 857375b..a0ec698 100644 --- a/src/lib/item-size.test.ts +++ b/src/lib/item-size.test.ts @@ -13,6 +13,7 @@ import { compareSizes, describeSize, parseItemSize, + sizeRank, SIZE_RANK_EXACT, SIZE_RANK_KIND, SIZE_RANK_NEAR_BASE, @@ -330,3 +331,34 @@ describe("describeSize", () => { expect(describeSize(parseItemSize("Widget"))).toBe(""); }); }); + +describe("sizeRank — seen on the live POS 2026-10-24", () => { + it("ranks an item that doesn't name its type above a wrong length", () => { + const line = parseItemSize("M12 x 40 cap screw 12.9"); + const allen = sizeRank(line, parseItemSize("M12 X 40 ALLEN CAP")); + const shorter = sizeRank(line, parseItemSize("M12 x 30 cap screw")); + expect(allen).toBeLessThan(shorter); + }); + + it("ranks a same-thread item with no length behind shorter same-kind items", () => { + const line = parseItemSize("M12 x 40 cap screw 12.9"); + expect(parseItemSize("M12 WSHR 4.8 ZP").kind).toBe("washer"); + const washer = sizeRank(line, parseItemSize("M12 WSHR 4.8 ZP")); + const shorter = sizeRank(line, parseItemSize("M12 x 30 cap screw")); + expect(shorter).toBeLessThan(washer); + }); + + it("reads allen cap / socket cap as a cap screw", () => { + expect(parseItemSize("M12 X 40 ALLEN CAP").kind).toBe("cap_screw"); + expect(parseItemSize("M10x25 socket cap").kind).toBe("cap_screw"); + }); + + it("ranks a matching dimension above a conflicting one for non-fasteners", () => { + const line = parseItemSize('Funnel 150mm PVC round 2 piece Flexible ("6") Round'); + const brush = sizeRank(line, parseItemSize("PAINT BRUSH, 150mm BLONDIE")); + const trimmer = sizeRank(line, parseItemSize("HUSQVARNA TRIMMER LINE 2.0MM X 15M")); + const plain = sizeRank(line, parseItemSize("PVC FUNNEL")); + expect(brush).toBeLessThan(plain); + expect(plain).toBeLessThan(trimmer); + }); +}); diff --git a/src/lib/item-size.ts b/src/lib/item-size.ts index fec8333..5568d01 100644 --- a/src/lib/item-size.ts +++ b/src/lib/item-size.ts @@ -97,6 +97,10 @@ export const SIZE_RANK_THREAD = 1000; export const SIZE_RANK_KIND = 2000; /** Nothing comparable. */ export const SIZE_RANK_OTHER = 3000; +/** Length gap used for a same-thread candidate that states no length. */ +const NO_LENGTH_GAP = 500; +/** Added to SIZE_RANK_OTHER when a stated dimension conflicts. */ +export const SIZE_RANK_CONFLICT = 500; // ─── Word lists ──────────────────────────────────────────────────────── @@ -112,10 +116,12 @@ const KIND_PHRASES: Array<{ re: RegExp; kind: ItemSizeKind }> = [ { re: /\bhex\s*head\s*bolts?\b/, kind: "bolt" }, { re: /\bhex\s*bolts?\b/, kind: "bolt" }, { re: /\bbolts?\b/, kind: "bolt" }, - { re: /\bwashers?\b/, kind: "washer" }, + { re: /\b(?:washers?|wshrs?|wash)\b/, kind: "washer" }, { re: /\bset\s*screws?\b/, kind: "setscrew" }, { re: /\bset\s*scr\b/, kind: "setscrew" }, { re: /\bgrub\s*screws?\b/, kind: "setscrew" }, + { re: /\b(?:allen|socket)\s*caps?\b/, kind: "cap_screw" }, + { re: /\bcap\s*heads?\b/, kind: "cap_screw" }, { re: /\bcap\s*screws?\b/, kind: "cap_screw" }, { re: /\bsocket\s*head\s*cap\s*screws?\b/, kind: "cap_screw" }, { re: /\bstuds?\b/, kind: "stud" }, @@ -406,22 +412,52 @@ export function compareSizes(line: ItemSize, cand: ItemSize): SizeCompare { export function sizeRank(line: ItemSize, cand: ItemSize): number { if (!hasSizeInfo(line)) return SIZE_RANK_OTHER; - const kindSame = line.kind !== null && cand.kind !== null && cand.kind === line.kind; + // A type the candidate doesn't state ("M12 X 40 ALLEN CAP" before the + // parser knew the word) is compatible, just behind a stated match; a + // stated different type is not. + const kindKnownSame = line.kind !== null && cand.kind === line.kind; + const kindOk = line.kind === null || cand.kind === null || cand.kind === line.kind; + const kindPenalty = kindKnownSame || line.kind === null ? 0 : 1; const threadSame = line.thread !== null && cand.thread !== null && cand.thread === line.thread; const lengthSame = line.length !== null && cand.length !== null && cand.length === line.length; - if (kindSame && threadSame && lengthSame) return SIZE_RANK_EXACT; - if (kindSame && threadSame) { - const gap = - line.length !== null && cand.length !== null - ? Math.min(999, Math.abs(line.length - cand.length)) - : 0; - return SIZE_RANK_NEAR_BASE + gap; + if (line.thread !== null) { + if (kindOk && threadSame && lengthSame) return SIZE_RANK_EXACT + kindPenalty; + if (kindOk && threadSame) { + // A candidate with no length at all (a washer, a nut) sits behind + // every same-thread item that has one. + const gap = + line.length === null + ? 0 + : cand.length === null + ? NO_LENGTH_GAP + : Math.min(999, Math.abs(line.length - cand.length)); + return SIZE_RANK_NEAR_BASE + kindPenalty + gap; + } + if (threadSame) return SIZE_RANK_THREAD; + if (kindKnownSame) return SIZE_RANK_KIND; + return SIZE_RANK_OTHER; } - if (threadSame) return SIZE_RANK_THREAD; - if (kindSame) return SIZE_RANK_KIND; + + // No thread: rank on the other dimensions (150mm, 20L, 6in). Every + // stated dimension matching is best; a conflicting one (2mm vs 150mm) + // is worse than the candidate not stating it at all. + if (line.dims.length > 0) { + let matched = 0; + let conflicts = 0; + for (const dim of line.dims) { + const same = cand.dims.find((d) => d.unit === dim.unit); + if (!same) continue; + if (same.value === dim.value) matched++; + else conflicts++; + } + if (conflicts === 0 && matched === line.dims.length) return SIZE_RANK_EXACT + kindPenalty; + if (conflicts === 0 && matched > 0) return SIZE_RANK_NEAR_BASE + kindPenalty; + if (conflicts > 0) return SIZE_RANK_OTHER + SIZE_RANK_CONFLICT; + } + if (kindKnownSame) return SIZE_RANK_KIND; return SIZE_RANK_OTHER; } diff --git a/src/lib/server/pos-match.ts b/src/lib/server/pos-match.ts index 9effce1..fbdf264 100644 --- a/src/lib/server/pos-match.ts +++ b/src/lib/server/pos-match.ts @@ -384,6 +384,27 @@ export interface PickPosDeps { type DecideInput = import("./quote-assistant-ai").DecideInput; type DecideResult = import("./quote-assistant-ai").DecideResult; +/** + * The AI's "exact" only stands when our own size check agrees. If the + * parsed sizes of the line and the chosen item really conflict (length + * 65 vs 60, thread M22 vs M16 — both sides known), the pick is downgraded + * to "closest" so no price is written for it, and the conflicts are added + * to `differs`. A field the item simply doesn't state ("?") is not a + * conflict: the AI may know better than the parser there. + */ +export function confirmPosMatch( + lineDescription: string, + chosen: Pick | undefined, + match: PosMatchKind, + differs: string[], +): { match: PosMatchKind; differs: string[] } { + if (match !== "exact" || !chosen) return { match, differs }; + const conflicts = compareSizes(parseItemSize(lineDescription), parseCandidateSize(chosen)) + .differs.filter((d) => !d.includes(" ? ") && !d.includes("? vs")); + if (conflicts.length === 0) return { match, differs }; + return { match: "closest", differs: [...new Set([...differs, ...conflicts])] }; +} + /** * Let the AI pick a POS item for each line. * @@ -433,12 +454,20 @@ export async function pickPosItems( }); model = out.model || model; for (const [lineId, decision] of out.decisions) { + const sent = decideLines.find((l) => l.quote_item_id === lineId); + const chosen = sent?.candidates.find((c) => c.id === decision.stock_item_id); + const confirmed = confirmPosMatch( + sent?.description ?? "", + chosen, + // An old response with no `match` meant "exact". + decision.match ?? (decision.stock_item_id ? "exact" : "none"), + decision.differs ?? [], + ); picks.set(lineId, { line_id: lineId, stock_item_id: decision.stock_item_id, - // An old response with no `match` meant "exact". - match: decision.match ?? (decision.stock_item_id ? "exact" : "none"), - differs: decision.differs ?? [], + match: confirmed.match, + differs: confirmed.differs, confidence: decision.confidence, reason: decision.reason, }); diff --git a/src/lib/server/quote-assistant-core.test.ts b/src/lib/server/quote-assistant-core.test.ts index 1b20352..4d94f15 100644 --- a/src/lib/server/quote-assistant-core.test.ts +++ b/src/lib/server/quote-assistant-core.test.ts @@ -297,6 +297,31 @@ describe("buildLinePatch", () => { expect(fill.closest).toBeUndefined(); }); + it("writes no price when the AI says exact but the sizes really conflict", () => { + const patch = buildLinePatch({ + ...base, + item: line({ description: "BOLT HEX M16X60 GR8.8 ZP" }), + decision: decision({ match: "exact", confidence: "high", differs: [] }), + candidates: [cand({ name: "M22x65 BOLT 8.8 ZP", description: "M22x65 BOLT 8.8 ZP" })], + }); + expect(patch).not.toHaveProperty("cost_price"); + const fill = patch.ai_fill as QuoteItemAiFill; + expect(fill.status).toBe("no_match"); + expect(fill.closest?.differs).toEqual( + expect.arrayContaining(["thread M22 vs M16", "length 65 vs 60"]), + ); + }); + + it("keeps an exact pick when the item just doesn't state a size", () => { + const patch = buildLinePatch({ + ...base, + item: line({ description: "BOLT HEX M16X60 GR8.8 ZP" }), + decision: decision({ match: "exact", confidence: "high" }), + candidates: [cand({ name: "HB1660ZP", description: "", sku: "HB1660ZP" })], + }); + expect(patch).toHaveProperty("cost_price"); + }); + it("writes the price for an exact match", () => { const patch = buildLinePatch({ ...base, diff --git a/src/lib/server/quote-assistant-core.ts b/src/lib/server/quote-assistant-core.ts index 6c78cdb..cc71c9a 100644 --- a/src/lib/server/quote-assistant-core.ts +++ b/src/lib/server/quote-assistant-core.ts @@ -19,7 +19,7 @@ import type { QuoteItem, QuoteItemAiFill, } from "@/lib/types"; -import type { StockCandidate } from "@/lib/server/pos-match"; +import { confirmPosMatch, type StockCandidate } from "@/lib/server/pos-match"; import { DEFAULT_MARKUP_MULTIPLIER, DEFAULT_MARKUP_PERCENT, @@ -211,8 +211,15 @@ export function buildLinePatch(args: { const cost = chosen ? round2(Number(chosen.cost_price) || 0) : 0; // Only an EXACT match may write a price. A missing `match` on an old - // response reads as "exact" (see `asMatchKind`). - const isExact = (decision?.match ?? "exact") === "exact"; + // response reads as "exact" (see `asMatchKind`), and an "exact" whose + // parsed sizes really conflict is downgraded to "closest". + const confirmed = confirmPosMatch( + item.description || "", + chosen, + decision?.match ?? "exact", + decision?.differs ?? [], + ); + const isExact = confirmed.match === "exact"; if (chosen && isExact && cost > 0 && meetsConfidence(decision?.confidence ?? null, minConfidence)) { const posSelling = round2(Number(chosen.selling_price) || 0); @@ -255,14 +262,14 @@ export function buildLinePatch(args: { // The closest item — recorded, but never priced onto the line. const closest = - !status && chosen && decision?.match === "closest" + !status && chosen && confirmed.match === "closest" ? { stock_item_id: chosen.id, name: chosen.name, sku: chosen.sku, cost_price: cost, selling_price: round2(Number(chosen.selling_price) || 0), - differs: decision.differs ?? [], + differs: confirmed.differs, } : undefined;