fix(pos-match): our size check backs the AI's "exact"; rank fixes from the live POS
- confirmPosMatch: an AI "exact" whose parsed sizes really conflict
(thread M22 vs M16, length 65 vs 60) becomes "closest", so no price
is written; a field the POS item just doesn't state is not a conflict
- ranking, checked read-only against the live POS: an item that doesn't
name its type ("M12 X 40 ALLEN CAP") no longer drops behind wrong
lengths; a same-thread item with no length (washers) sits behind
same-kind items that have one; non-fasteners rank on their dimensions
(150mm) with a conflicting size below none
- allen cap / socket cap / cap head read as cap screw, WSHR as washer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
33813b4264
commit
fa68b14388
5 changed files with 148 additions and 19 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<StockCandidate, "name" | "description" | "sku"> | 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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue