WFDASH-WEBAPP/src/lib/item-size.ts
Dave fa68b14388 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>
2026-10-24 23:22:47 +02:00

478 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* item-size — parse the size out of a free-text item description.
*
* Pure and client-safe: no PocketBase, no AI, no DOM. Both the server
* (POS matcher, quote assistant) and the client (price check popover)
* can use it.
*
* ## Why
*
* The POS shortlist used to rank candidates by *shared words* only. For
* "BOLT HEX M16X60 GR8.8 ZP" every bolt in the catalogue shares
* "BOLT 8.8 ZP" and scores 0.75 — the M16 that the customer actually
* asked for never reached the AI, and the price check showed the price
* of an M22.
*
* Size is the thing that tells two otherwise identical items apart.
* This module pulls it out of the text so the matcher can compare it.
*
* ## What comes out
*
* parseItemSize("BOLT HEX M16X60 GR8.8 ZP")
* → { kind: "bolt", variant: null, thread: "M16", length: 60,
* grade: "8.8", finish: "zp", dims: [] }
*
* parseItemSize("NUT NYLOC M16 ZINC")
* → { kind: "nut", variant: "nyloc", thread: "M16", …, finish: "zp" }
*
* parseItemSize('Funnel 150mm PVC round 2 piece Flexible ("6") Round')
* → { kind: null, …, dims: [{ value: 150, unit: "mm" },
* { value: 6, unit: "in" }] }
*
* `compareSizes(line, candidate)` then says whether a candidate is the
* same size as the line, and in plain words what differs when it is
* not.
*/
/** The normalised type of the item. `null` when the text names no type. */
export type ItemSizeKind =
| "bolt"
| "nut"
| "washer"
| "setscrew"
| "cap_screw"
| "stud"
| "anchor"
| "rivet"
| null;
/** A qualifier of the kind — "nyloc" nut, "flat"/"spring" washer. */
export type ItemSizeVariant = "nyloc" | "flat" | "spring" | null;
/** The surface finish. */
export type ItemSizeFinish = "zp" | "hdg" | "ss" | "black" | null;
/** A number with its unit — "150mm", "20L", 6". */
export interface ItemSizeDim {
value: number;
/** Normalised unit: "mm", "cm", "m", "l", "kg", "g", "in", "ft". */
unit: string;
}
/** Everything the parser could tell about one description. */
export interface ItemSize {
kind: ItemSizeKind;
variant: ItemSizeVariant;
/** Metric ("M16") or imperial ("1/2UNC", "1/2\"") thread. */
thread: string | null;
/** The length that follows the thread — 60 in "M16x60". */
length: number | null;
/** Property class / grade — "8.8", "10.9". */
grade: string | null;
finish: ItemSizeFinish;
/** Other numbers with units, for non-fasteners. */
dims: ItemSizeDim[];
}
/** The result of comparing a line's size with a candidate's size. */
export interface SizeCompare {
/** True when every attribute the LINE states is matched by the candidate. */
exact: boolean;
/** Plain words: "length 65 vs 60", "thread M22 vs M16". */
differs: string[];
/** Sort key — lower is a better match. */
rank: number;
}
// ─── Ranks ─────────────────────────────────────────────────────────────
/** Same kind, same thread, same length. */
export const SIZE_RANK_EXACT = 0;
/** Same kind + thread, different length — the bucket starts here and the
* length distance is added, so the nearest length sorts first. */
export const SIZE_RANK_NEAR_BASE = 10;
/** Same thread, but a different kind (or no kind at all). */
export const SIZE_RANK_THREAD = 1000;
/** Same kind, but a different thread. */
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 ────────────────────────────────────────────────────────
/**
* Type words, longest phrase first so "hex head bolt" wins over "bolt".
* The phrase only decides the *kind* — the variant ("flat", "spring",
* "nyloc") is worked out afterwards from the kind.
*/
const KIND_PHRASES: Array<{ re: RegExp; kind: ItemSizeKind }> = [
{ re: /\block\s*nuts?\b/, kind: "nut" },
{ re: /\bhex\s*nuts?\b/, kind: "nut" },
{ re: /\bnuts?\b/, kind: "nut" },
{ re: /\bhex\s*head\s*bolts?\b/, kind: "bolt" },
{ re: /\bhex\s*bolts?\b/, kind: "bolt" },
{ re: /\bbolts?\b/, kind: "bolt" },
{ 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" },
{ re: /\bchemical\s*anchors?\b/, kind: "anchor" },
{ re: /\bwedge\s*anchors?\b/, kind: "anchor" },
{ re: /\banchors?\b/, kind: "anchor" },
{ re: /\brivets?\b/, kind: "rivet" },
];
/** The word used to pull same-kind rows out of the POS catalogue. */
export const KIND_WORDS: Record<string, string> = {
bolt: "bolt",
nut: "nut",
washer: "washer",
setscrew: "screw",
cap_screw: "screw",
stud: "stud",
anchor: "anchor",
rivet: "rivet",
};
/** Property classes we recognise. A bare number only counts when it is
* one of these — "8.8" is a grade, "60" is a length, "150" is a size. */
const KNOWN_GRADES = new Set(["4.6", "4.8", "5.6", "6.8", "8.8", "9.8", "10.9", "12.9"]);
/**
* Units we read a dimension in. The word boundary after the unit is
* what keeps "8.8 GRADE" from being read as 8.8 grams — after the "g"
* of GRADE comes an "r", so there is no boundary and the clause fails.
*/
const DIM_RE = /(\d+(?:\.\d+)?)\s*(mm|cm|litre|liter|inch|in|ft|kg|lt|l|g|m)\b/gi;
/** An inch mark: 6" */
const INCH_RE = /(\d+(?:\.\d+)?)\s*"/g;
/** Finish words, in the order we test them. */
const FINISH_PATTERNS: Array<{ re: RegExp; finish: ItemSizeFinish }> = [
{ re: /\bzinc\s*plated\b/, finish: "zp" },
{ re: /\bhot\s*dip\s*galv(?:anised|anized)?\b/, finish: "hdg" },
{ re: /\bgalv(?:anised|anized)?\b/, finish: "hdg" },
{ re: /\bhdg\b/, finish: "hdg" },
{ re: /\bstainless\b/, finish: "ss" },
{ re: /\bss\b/, finish: "ss" },
{ re: /\b304\b/, finish: "ss" },
{ re: /\b316\b/, finish: "ss" },
{ re: /\bzinc\b/, finish: "zp" },
{ re: /\bzp\b/, finish: "zp" },
{ re: /\bself\s*colou?r\b/, finish: "black" },
{ re: /\bblack\b/, finish: "black" },
];
// ─── Helpers ───────────────────────────────────────────────────────────
/** Replace a span of the text with spaces so later passes ignore it. */
function mask(text: string, from: number, to: number): string {
if (from < 0 || to <= from) return text;
return text.slice(0, from) + " ".repeat(to - from) + text.slice(to);
}
function parseKind(text: string): { kind: ItemSizeKind; variant: ItemSizeVariant } {
// "nyloc" names the type by itself — "NYLOC M16" is a nut.
if (/\bnyloc?k?\b/.test(text)) return { kind: "nut", variant: "nyloc" };
let kind: ItemSizeKind = null;
for (const entry of KIND_PHRASES) {
if (entry.re.test(text)) {
kind = entry.kind;
break;
}
}
if (kind === null) return { kind: null, variant: null };
// The variant of the kind — the word may come before or after the
// type word ("flat washer" and "washer flat" are the same thing).
if (kind === "washer") {
if (/\bspring\b/.test(text)) return { kind, variant: "spring" };
if (/\bflat\b/.test(text)) return { kind, variant: "flat" };
}
return { kind, variant: null };
}
/**
* The metric thread and the length that follows it.
*
* Handles "M16", "M16X60", "M16 x 60", "m16x60mm" and the bracketed
* form "M24(400)".
*/
function parseThread(
text: string,
): { thread: string | null; length: number | null; spanStart: number; spanEnd: number } {
const metric = /\bM\s*(\d{1,3}(?:\.\d)?)/i.exec(text);
if (metric) {
const thread = `M${metric[1]}`;
const rest = text.slice(metric.index + metric[0].length);
let length: number | null = null;
let spanEnd = metric.index + metric[0].length;
const byX = /^\s*[x×]\s*(\d+(?:\.\d+)?)/i.exec(rest);
if (byX) {
length = Number(byX[1]);
spanEnd += byX[0].length;
} else {
const byBracket = /^\s*\(\s*(\d+(?:\.\d+)?)/.exec(rest);
if (byBracket) {
length = Number(byBracket[1]);
spanEnd += byBracket[0].length;
}
}
return { thread, length, spanStart: metric.index, spanEnd };
}
// Imperial: 1/2", 1/2 UNC, 1/4-20 UNC
const imperial = /(\d{1,2})\s*\/\s*(\d{1,2})\s*(UNC|UNF|BSW|BSF)?/i.exec(text);
if (imperial) {
const suffix = imperial[3] ? imperial[3].toUpperCase() : "";
const after = text.slice(imperial.index + imperial[0].length);
const inchMark = /^\s*"/.test(after) ? '"' : "";
return {
thread: `${imperial[1]}/${imperial[2]}${suffix}${inchMark}`,
length: null,
spanStart: imperial.index,
spanEnd: imperial.index + imperial[0].length,
};
}
return { thread: null, length: null, spanStart: 0, spanEnd: 0 };
}
function parseGrade(text: string): string | null {
// "GR8.8", "grade 8.8", "class 10.9"
const labelled = /\b(?:gr|grade|class)\s*\.?\s*(\d{1,2}(?:\.\d)?)\b/i.exec(text);
if (labelled) {
const value = labelled[1];
if (KNOWN_GRADES.has(value)) return value;
}
// A bare number only counts when it is a known property class.
for (const m of text.matchAll(/\b(\d{1,2}\.\d)\b/g)) {
if (KNOWN_GRADES.has(m[1])) return m[1];
}
return null;
}
function parseFinish(text: string): ItemSizeFinish {
for (const entry of FINISH_PATTERNS) {
if (entry.re.test(text)) return entry.finish;
}
return null;
}
function parseDims(text: string): ItemSizeDim[] {
const found: Array<{ dim: ItemSizeDim; at: number }> = [];
for (const m of text.matchAll(DIM_RE)) {
const value = Number(m[1]);
if (!Number.isFinite(value)) continue;
found.push({ dim: { value, unit: m[2].toLowerCase() }, at: m.index ?? 0 });
}
for (const m of text.matchAll(INCH_RE)) {
const value = Number(m[1]);
if (!Number.isFinite(value)) continue;
found.push({ dim: { value, unit: "in" }, at: m.index ?? 0 });
}
// Keep the order the numbers appeared in.
return found.sort((a, b) => a.at - b.at).map((f) => f.dim);
}
/**
* Parse the size out of a description.
*
* Never throws and never guesses: an attribute the text does not state
* stays `null` so it never counts against a candidate later.
*/
export function parseItemSize(text: string): ItemSize {
const raw = typeof text === "string" ? text : "";
const lower = raw.toLowerCase();
const { kind, variant } = parseKind(lower);
const { thread, length, spanStart, spanEnd } = parseThread(raw);
// The thread + length run is not a dimension — "M16x60mm" is one bolt,
// not a 60 mm pipe. Blank it out before the dimension pass.
const dimText = thread ? mask(raw, spanStart, spanEnd) : raw;
return {
kind,
variant,
thread,
length,
grade: parseGrade(lower),
finish: parseFinish(lower),
dims: parseDims(dimText),
};
}
// ─── Comparison ────────────────────────────────────────────────────────
/** Does the line say anything about size at all? */
export function hasSizeInfo(size: ItemSize): boolean {
return (
size.kind !== null ||
size.variant !== null ||
size.thread !== null ||
size.length !== null ||
size.grade !== null ||
size.finish !== null ||
size.dims.length > 0
);
}
/** "length ? vs 60" — the candidate simply does not say. */
const UNKNOWN = "?";
/**
* Compare the line's size with a candidate's.
*
* - `exact` is true only when every attribute the LINE states is
* matched by the candidate. Attributes the line does not mention never
* count against the candidate.
* - `differs` spells the differences out in plain words, candidate
* first: "length 65 vs 60".
* - `rank` sorts the candidates: same kind + thread + length first,
* then same kind + thread with the nearest length, then the same
* thread, then the rest.
*/
export function compareSizes(line: ItemSize, cand: ItemSize): SizeCompare {
const differs: string[] = [];
if (line.kind !== null) {
if (cand.kind === null) differs.push(`kind ${UNKNOWN} vs ${line.kind}`);
else if (cand.kind !== line.kind) differs.push(`kind ${cand.kind} vs ${line.kind}`);
}
if (line.variant !== null) {
if (cand.variant === null) differs.push(`variant ${UNKNOWN} vs ${line.variant}`);
else if (cand.variant !== line.variant) {
differs.push(`variant ${cand.variant} vs ${line.variant}`);
}
}
if (line.thread !== null) {
if (cand.thread === null) differs.push(`thread ${UNKNOWN} vs ${line.thread}`);
else if (cand.thread !== line.thread) differs.push(`thread ${cand.thread} vs ${line.thread}`);
}
if (line.length !== null) {
if (cand.length === null) differs.push(`length ${UNKNOWN} vs ${line.length}`);
else if (cand.length !== line.length) {
differs.push(`length ${cand.length} vs ${line.length}`);
}
}
if (line.grade !== null) {
if (cand.grade === null) differs.push(`grade ${UNKNOWN} vs ${line.grade}`);
else if (cand.grade !== line.grade) differs.push(`grade ${cand.grade} vs ${line.grade}`);
}
if (line.finish !== null) {
if (cand.finish === null) differs.push(`finish ${UNKNOWN} vs ${line.finish}`);
else if (cand.finish !== line.finish) {
differs.push(`finish ${cand.finish.toUpperCase()} vs ${line.finish.toUpperCase()}`);
}
}
for (const dim of line.dims) {
const sameUnit = cand.dims.find((d) => d.unit === dim.unit);
if (sameUnit) {
if (sameUnit.value !== dim.value) {
differs.push(`size ${sameUnit.value}${sameUnit.unit} vs ${dim.value}${dim.unit}`);
}
} else {
// The candidate does not state a dimension in this unit at all.
differs.push(`size ${UNKNOWN} vs ${dim.value}${dim.unit}`);
}
}
const exact = hasSizeInfo(line) && differs.length === 0;
return { exact, differs, rank: sizeRank(line, cand) };
}
/**
* The sort key. Lower is better:
*
* 0 same kind + thread + length
* 10 + Δlen same kind + thread, nearest length first
* 1000 same thread
* 2000 same kind, different thread
* 3000 nothing comparable
*/
export function sizeRank(line: ItemSize, cand: ItemSize): number {
if (!hasSizeInfo(line)) return SIZE_RANK_OTHER;
// 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 (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;
}
// 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;
}
/**
* One short line describing a parsed size — for the AI prompt and the
* popover. Empty when the text said nothing about size.
*/
export function describeSize(size: ItemSize): string {
const bits: string[] = [];
if (size.kind) bits.push(size.kind);
if (size.variant) bits.push(size.variant);
if (size.thread) bits.push(`thread ${size.thread}`);
if (size.length !== null) bits.push(`length ${size.length}`);
if (size.grade) bits.push(`grade ${size.grade}`);
if (size.finish) bits.push(`finish ${size.finish}`);
for (const dim of size.dims) bits.push(`size ${dim.value}${dim.unit}`);
return bits.join(", ");
}