- 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>
478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
/**
|
||
* 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(", ");
|
||
}
|