From b23626b10c319a9ba8c7595e81ff08d7d93ba1c7 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 21:17:00 +0200 Subject: [PATCH] docs(spec,plan): the SKU text also gets matched as text The operator decided that when a pasted SKU matches nothing exactly, the SKU string should get a second chance at matching by how the item is named - pasting "M16 Flat Washer" into the SKU box should still find the flat washer. The line's match text is now the description and the SKU joined, so a line carrying only a SKU still gets a text match. Everything else about the text step is unchanged: the same 0.6 threshold, the same "top candidate wins unless two are tied at the top" rule. Also from the Task 2 review: an inactive best match now names itself instead of vanishing, the resolver truncates the quantity so the dry-run plan and the stored value agree, and the test list picks up the fall-through path that had no coverage. --- ...026-11-05-giellie-stock-quantity-update.md | 30 +++++++++++++++++++ ...05-giellie-stock-quantity-update-design.md | 8 ++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md b/docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md index 09b7293..9b69855 100644 --- a/docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md +++ b/docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md @@ -262,6 +262,36 @@ Pure matching, no PocketBase. Takes the candidate rows as an argument so it is t **Note on the description rule.** The operator chose "use it, flag it as fuzzy" for the case where the description *clearly identifies one item*. So the description step ranks the candidates and takes the top one — unless two candidates are tied at the top, which is the genuine ambiguity. A single threshold alone would report "ambiguous" for almost every description, which defeats the feature. +### Task 2 amendments (operator decision 2026-11-05, plus Task 2 review findings) + +The steps below are the original task text. Four changes were decided after it was first implemented and reviewed. **They govern.** The original Step 3 code is superseded on these points; the original Step 1 test list is superseded by the list here. + +**Amendment 1 — the SKU text is also matched as text.** The operator decided that when the exact SKU misses, the SKU string gets a second chance at matching by how the item is named. + +- The line's match text is the description **and** the SKU joined: `const matchText = [description, sku].filter(Boolean).join(" ")`. When there is no description, the match text is the SKU alone. +- The text step scores `matchText` against the candidate's name + description. Everything else about the text step is unchanged: the same 0.6 threshold, the same "top candidate wins unless two are tied at the top" rule, the same `match_method: "description_fuzzy"`. +- The match method stays `"description_fuzzy"` — it is a text match either way. + +**Amendment 2 — an inactive best match names itself.** The original implementation filtered inactive rows out of the text step before scoring, so an inactive best match vanished and the report said only "no active stock item matches". The spec requires the reason to be stated. + +- The text step scores **all** candidates, active and inactive, exactly as the SKU step already does. +- If the top candidate is inactive, the line is reported as unmatched with a message naming the item and saying it is inactive — the same treatment the SKU step already gives. +- An inactive row is never written to, in either step. + +**Amendment 3 — the resolver truncates the quantity.** Quantities are whole units by convention and the global constraint says every write truncates. Truncating in the resolver makes the dry-run plan and the stored value agree, instead of the plan reporting 12.7 for a write that stores 12. + +- `resolveStockQuantityLines` returns `qty: Number.isFinite(line.qty) ? Math.trunc(line.qty) : line.qty`. + +**Amendment 4 — test list.** The test file must cover, in addition to the original tests: + +- a line whose SKU matches nothing but whose text does match — this is the operator's decision #3 and the fall-through path, previously untested; +- a line whose SKU matches nothing and whose SKU text matches by how the item is named — Amendment 1; +- an inactive best match in the text step reporting the reason — Amendment 2; +- the SKU-ambiguity case asserting that nothing is written (`item` is undefined), not just the status and candidate count; +- the quantity being truncated to a whole unit. + +The original Step 1 ambiguity fixture could not pass against the original Step 3 code: the query `"BOLT-M6"` normalises to `"BOLTM6"` while `"BOLT-M6-40"` normalises to `"BOLTM640"`, so it never produced two rows sharing a normalised SKU. The fixture must put two rows on the same normalised key and query that key. + - [ ] **Step 1: Write the failing test** Create `src/lib/server/stock-quantity-match.test.ts`: diff --git a/docs/superpowers/specs/2026-11-05-giellie-stock-quantity-update-design.md b/docs/superpowers/specs/2026-11-05-giellie-stock-quantity-update-design.md index 7ea6dcb..b1c5d47 100644 --- a/docs/superpowers/specs/2026-11-05-giellie-stock-quantity-update-design.md +++ b/docs/superpowers/specs/2026-11-05-giellie-stock-quantity-update-design.md @@ -73,7 +73,7 @@ Named for symmetry with the existing `stock_items.set_pricing`. | Step | Rule | Reuses | |---|---|---| | 1 | Exact SKU, normalised (uppercase, strip `-_/()[]` and other noise) | `normaliseSku` — `src/lib/stock-match.ts:47` | -| 2 | Description fuzzy — Jaccard ≥ 0.6 over tokenised description vs name + description | `tokenize` + `jaccard` — `src/lib/server/order-quote-match.ts` | +| 2 | Text fuzzy — Jaccard ≥ 0.6 over the tokenised **line text** vs the item's name + description. The line text is the description **and** the SKU joined, so a line that only carries a SKU still gets a second chance at matching by how the item is named. | `tokenize` + `jaccard` — `src/lib/server/order-quote-match.ts` | Resolution outcomes: @@ -81,7 +81,7 @@ Resolution outcomes: - **2+ candidates** → **ambiguous**. Nothing is written. The candidates come back with their ids, names and current quantities so the operator can pick. - **0 candidates** → **unmatched**. Nothing is written. The line is reported. -Matching runs against active items. An inactive item that matches is reported as unmatched with the reason stated, rather than silently written to. +An inactive item is never written to. When the best match is an inactive item — in either step — it is reported as unmatched with the reason stated, rather than silently dropped. **Write semantics.** The tool never does arithmetic that the model could get wrong; it resolves, computes the delta and writes through the existing audited service. @@ -215,7 +215,7 @@ Because `public/pos/` is served cache-first by the service worker, the cache con ## The conversation, end to end -> **Operator:** `ABC-123 50` / `BOLT-6MM 120` / `ZZZ-9 30` +> **Operator:** `ABC-123 Bolt M12 x 60 50` / `M6-40MM Hex bolt M6 x 40mm 120` / `ZZZ-9 30` > **Giellie:** *Is this the new qty on hand, or should I add these to the current stock?* > **Operator:** *on hand* > **Giellie:** *(dry run → plan table)* @@ -223,7 +223,7 @@ Because `public/pos/` is served cache-first by the service worker, the cache con | SKU | Item | Before | After | Status | |---|---|---|---|---| | ABC-123 | Bolt M12 x 60 | 12 | 50 | applied | -| BOLT-6MM | Bolt M6 40mm | 5 | 120 | applied · matched by description | +| M6-40MM | Bolt M6 40mm | 5 | 120 | applied · matched by description | | ZZZ-9 | — | — | — | **not found** | > **Operator:** *apply*