mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
feat: Fixes to suggestions
This commit is contained in:
parent
8ff73428a2
commit
273763dd58
1 changed files with 110 additions and 22 deletions
|
|
@ -1,8 +1,20 @@
|
||||||
import { esc, renderThumbHtml } from "./dom.js";
|
import { esc, renderThumbHtml } from "./dom.js";
|
||||||
import { tokenizeQuery, matchesAllTokens, isUnknownSkuKey, displaySku, keySkuForRow, normSearchText } from "./sku.js";
|
import {
|
||||||
|
tokenizeQuery,
|
||||||
|
matchesAllTokens,
|
||||||
|
isUnknownSkuKey,
|
||||||
|
displaySku,
|
||||||
|
keySkuForRow,
|
||||||
|
normSearchText,
|
||||||
|
} from "./sku.js";
|
||||||
import { loadIndex } from "./state.js";
|
import { loadIndex } from "./state.js";
|
||||||
import { aggregateBySku } from "./catalog.js";
|
import { aggregateBySku } from "./catalog.js";
|
||||||
import { isLocalWriteMode, loadSkuMetaBestEffort, apiWriteSkuLink, apiWriteSkuIgnore } from "./api.js";
|
import {
|
||||||
|
isLocalWriteMode,
|
||||||
|
loadSkuMetaBestEffort,
|
||||||
|
apiWriteSkuLink,
|
||||||
|
apiWriteSkuIgnore,
|
||||||
|
} from "./api.js";
|
||||||
import { loadSkuRules } from "./mapping.js";
|
import { loadSkuRules } from "./mapping.js";
|
||||||
|
|
||||||
/* ---------------- Similarity helpers ---------------- */
|
/* ---------------- Similarity helpers ---------------- */
|
||||||
|
|
@ -10,7 +22,8 @@ import { loadSkuRules } from "./mapping.js";
|
||||||
function levenshtein(a, b) {
|
function levenshtein(a, b) {
|
||||||
a = String(a || "");
|
a = String(a || "");
|
||||||
b = String(b || "");
|
b = String(b || "");
|
||||||
const n = a.length, m = b.length;
|
const n = a.length,
|
||||||
|
m = b.length;
|
||||||
if (!n) return m;
|
if (!n) return m;
|
||||||
if (!m) return n;
|
if (!m) return n;
|
||||||
|
|
||||||
|
|
@ -62,11 +75,28 @@ function fastSimilarityScore(aTokens, bTokens, aNormName, bNormName) {
|
||||||
|
|
||||||
const a = String(aNormName || "");
|
const a = String(aNormName || "");
|
||||||
const b = String(bNormName || "");
|
const b = String(bNormName || "");
|
||||||
const pref = a.slice(0, 10) && b.slice(0, 10) && a.slice(0, 10) === b.slice(0, 10) ? 0.2 : 0;
|
const pref =
|
||||||
|
a.slice(0, 10) && b.slice(0, 10) && a.slice(0, 10) === b.slice(0, 10)
|
||||||
|
? 0.2
|
||||||
|
: 0;
|
||||||
|
|
||||||
return overlap * 2.0 + pref;
|
return overlap * 2.0 + pref;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Store-overlap rule ---------------- */
|
||||||
|
|
||||||
|
function storesOverlap(aItem, bItem) {
|
||||||
|
const a = aItem?.stores;
|
||||||
|
const b = bItem?.stores;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
|
||||||
|
// stores are Set(storeLabel). Exact-label overlap is the intended rule.
|
||||||
|
for (const s of a) {
|
||||||
|
if (b.has(s)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- Mapping helpers ---------------- */
|
/* ---------------- Mapping helpers ---------------- */
|
||||||
|
|
||||||
function buildMappedSkuSet(links) {
|
function buildMappedSkuSet(links) {
|
||||||
|
|
@ -83,7 +113,9 @@ function buildMappedSkuSet(links) {
|
||||||
function openLinkHtml(url) {
|
function openLinkHtml(url) {
|
||||||
const u = String(url || "").trim();
|
const u = String(url || "").trim();
|
||||||
if (!u) return "";
|
if (!u) return "";
|
||||||
return `<a class="badge" href="${esc(u)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">open</a>`;
|
return `<a class="badge" href="${esc(
|
||||||
|
u
|
||||||
|
)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">open</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBCStoreLabel(label) {
|
function isBCStoreLabel(label) {
|
||||||
|
|
@ -120,8 +152,16 @@ function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) {
|
||||||
return scored.slice(0, limit).map((x) => x.it);
|
return scored.slice(0, limit).map((x) => x.it);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isIgnoredPairFn) {
|
function recommendSimilar(
|
||||||
if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus);
|
allAgg,
|
||||||
|
pinned,
|
||||||
|
limit,
|
||||||
|
otherPinnedSku,
|
||||||
|
mappedSkus,
|
||||||
|
isIgnoredPairFn
|
||||||
|
) {
|
||||||
|
if (!pinned || !pinned.name)
|
||||||
|
return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus);
|
||||||
|
|
||||||
const base = String(pinned.name || "");
|
const base = String(pinned.name || "");
|
||||||
const pinnedSku = String(pinned.sku || "");
|
const pinnedSku = String(pinned.sku || "");
|
||||||
|
|
@ -134,7 +174,11 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isI
|
||||||
if (it.sku === pinned.sku) continue;
|
if (it.sku === pinned.sku) continue;
|
||||||
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
||||||
|
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || ""))) continue;
|
// NEW: never suggest same-store pairs
|
||||||
|
if (storesOverlap(pinned, it)) continue;
|
||||||
|
|
||||||
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || "")))
|
||||||
|
continue;
|
||||||
|
|
||||||
const s = similarityScore(base, it.name || "");
|
const s = similarityScore(base, it.name || "");
|
||||||
if (s > 0) scored.push({ it, s });
|
if (s > 0) scored.push({ it, s });
|
||||||
|
|
@ -143,7 +187,7 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isI
|
||||||
return scored.slice(0, limit).map((x) => x.it);
|
return scored.slice(0, limit).map((x) => x.it);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FAST initial pairing (approx) with ignore-pair exclusion
|
// FAST initial pairing (approx) with ignore-pair exclusion + same-store exclusion
|
||||||
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) {
|
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) {
|
||||||
const items = allAgg.filter((it) => {
|
const items = allAgg.filter((it) => {
|
||||||
if (!it) return false;
|
if (!it) return false;
|
||||||
|
|
@ -160,7 +204,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
const itemNormName = new Map();
|
const itemNormName = new Map();
|
||||||
|
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const toks = Array.from(new Set(tokenizeQuery(it.name || ""))).filter(Boolean).slice(0, 10);
|
const toks = Array.from(new Set(tokenizeQuery(it.name || "")))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 10);
|
||||||
itemTokens.set(it.sku, toks);
|
itemTokens.set(it.sku, toks);
|
||||||
itemNormName.set(it.sku, normSearchText(it.name || ""));
|
itemNormName.set(it.sku, normSearchText(it.name || ""));
|
||||||
for (const t of toks) {
|
for (const t of toks) {
|
||||||
|
|
@ -183,6 +229,7 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
for (const t of aToks) {
|
for (const t of aToks) {
|
||||||
const arr = tokMap.get(t);
|
const arr = tokMap.get(t);
|
||||||
if (!arr) continue;
|
if (!arr) continue;
|
||||||
|
|
||||||
for (let i = 0; i < arr.length && cand.size < MAX_CAND_TOTAL; i++) {
|
for (let i = 0; i < arr.length && cand.size < MAX_CAND_TOTAL; i++) {
|
||||||
const b = arr[i];
|
const b = arr[i];
|
||||||
if (!b) continue;
|
if (!b) continue;
|
||||||
|
|
@ -193,6 +240,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
|
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
||||||
|
|
||||||
|
// NEW: never suggest same-store pairs
|
||||||
|
if (storesOverlap(a, b)) continue;
|
||||||
|
|
||||||
cand.set(bSku, b);
|
cand.set(bSku, b);
|
||||||
}
|
}
|
||||||
if (cand.size >= MAX_CAND_TOTAL) break;
|
if (cand.size >= MAX_CAND_TOTAL) break;
|
||||||
|
|
@ -239,6 +289,10 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
const bSku = String(p.b.sku || "");
|
const bSku = String(p.b.sku || "");
|
||||||
if (!aSku || !bSku || aSku === bSku) continue;
|
if (!aSku || !bSku || aSku === bSku) continue;
|
||||||
if (used.has(aSku) || used.has(bSku)) continue;
|
if (used.has(aSku) || used.has(bSku)) continue;
|
||||||
|
|
||||||
|
// NEW: extra guard
|
||||||
|
if (storesOverlap(p.a, p.b)) continue;
|
||||||
|
|
||||||
used.add(aSku);
|
used.add(aSku);
|
||||||
used.add(bSku);
|
used.add(bSku);
|
||||||
out.push({ a: p.a, b: p.b, score: p.score });
|
out.push({ a: p.a, b: p.b, score: p.score });
|
||||||
|
|
@ -264,7 +318,7 @@ export async function renderSkuLinker($app) {
|
||||||
|
|
||||||
<div class="card" style="padding:14px;">
|
<div class="card" style="padding:14px;">
|
||||||
<div class="small" style="margin-bottom:10px;">
|
<div class="small" style="margin-bottom:10px;">
|
||||||
Unknown SKUs are hidden. Existing mapped SKUs are excluded. LINK SKU writes map; IGNORE PAIR writes a "do-not-suggest" pair (local only).
|
Unknown SKUs are hidden. Existing mapped SKUs are excluded. Same-store pairs are never suggested. LINK SKU writes map; IGNORE PAIR writes a "do-not-suggest" pair (local only).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; gap:16px;">
|
<div style="display:flex; gap:16px;">
|
||||||
|
|
@ -355,14 +409,21 @@ export async function renderSkuLinker($app) {
|
||||||
const otherSku = otherPinned ? String(otherPinned.sku || "") : "";
|
const otherSku = otherPinned ? String(otherPinned.sku || "") : "";
|
||||||
|
|
||||||
if (tokens.length) {
|
if (tokens.length) {
|
||||||
const out = allAgg
|
let out = allAgg
|
||||||
.filter((it) => it && it.sku !== otherSku && !mappedSkus.has(String(it.sku)) && matchesAllTokens(it.searchText, tokens))
|
.filter(
|
||||||
|
(it) =>
|
||||||
|
it &&
|
||||||
|
it.sku !== otherSku &&
|
||||||
|
!mappedSkus.has(String(it.sku)) &&
|
||||||
|
matchesAllTokens(it.searchText, tokens)
|
||||||
|
)
|
||||||
.slice(0, 80);
|
.slice(0, 80);
|
||||||
|
|
||||||
// if the other side is pinned, also exclude ignored pairs
|
|
||||||
if (otherPinned) {
|
if (otherPinned) {
|
||||||
const oSku = String(otherPinned.sku || "");
|
const oSku = String(otherPinned.sku || "");
|
||||||
return out.filter((it) => !isIgnoredPair(oSku, String(it.sku || "")));
|
out = out.filter((it) => !isIgnoredPair(oSku, String(it.sku || "")));
|
||||||
|
// NEW: never show same-store pairs when other side pinned
|
||||||
|
out = out.filter((it) => !storesOverlap(otherPinned, it));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -392,11 +453,18 @@ export async function renderSkuLinker($app) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const other = side === "L" ? pinnedR : pinnedL;
|
const other = side === "L" ? pinnedR : pinnedL;
|
||||||
|
|
||||||
if (other && String(other.sku || "") === String(it.sku || "")) {
|
if (other && String(other.sku || "") === String(it.sku || "")) {
|
||||||
$status.textContent = "Not allowed: both sides cannot be the same SKU.";
|
$status.textContent = "Not allowed: both sides cannot be the same SKU.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NEW: enforce same-store rule even on manual pinning
|
||||||
|
if (other && storesOverlap(other, it)) {
|
||||||
|
$status.textContent = "Not allowed: both items belong to the same store.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (side === "L") pinnedL = pinnedL && pinnedL.sku === it.sku ? null : it;
|
if (side === "L") pinnedL = pinnedL && pinnedL.sku === it.sku ? null : it;
|
||||||
else pinnedR = pinnedR && pinnedR.sku === it.sku ? null : it;
|
else pinnedR = pinnedR && pinnedR.sku === it.sku ? null : it;
|
||||||
|
|
||||||
|
|
@ -418,7 +486,9 @@ export async function renderSkuLinker($app) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = sideItems(side, query, other);
|
const items = sideItems(side, query, other);
|
||||||
$list.innerHTML = items.length ? items.map((it) => renderCard(it, false)).join("") : `<div class="small">No matches.</div>`;
|
$list.innerHTML = items.length
|
||||||
|
? items.map((it) => renderCard(it, false)).join("")
|
||||||
|
: `<div class="small">No matches.</div>`;
|
||||||
attachHandlers($list, side);
|
attachHandlers($list, side);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -426,7 +496,8 @@ export async function renderSkuLinker($app) {
|
||||||
if (!localWrite) {
|
if (!localWrite) {
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.disabled = true;
|
||||||
$ignoreBtn.disabled = true;
|
$ignoreBtn.disabled = true;
|
||||||
$status.textContent = "Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1.";
|
$status.textContent =
|
||||||
|
"Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -447,9 +518,17 @@ export async function renderSkuLinker($app) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NEW: same-store rule
|
||||||
|
if (storesOverlap(pinnedL, pinnedR)) {
|
||||||
|
$linkBtn.disabled = true;
|
||||||
|
$ignoreBtn.disabled = true;
|
||||||
|
$status.textContent = "Not allowed: both items belong to the same store.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (mappedSkus.has(a) || mappedSkus.has(b)) {
|
if (mappedSkus.has(a) || mappedSkus.has(b)) {
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.disabled = true;
|
||||||
$ignoreBtn.disabled = false; // still allow ignoring even if mapped? you can decide; default allow
|
$ignoreBtn.disabled = false;
|
||||||
} else {
|
} else {
|
||||||
$linkBtn.disabled = false;
|
$linkBtn.disabled = false;
|
||||||
$ignoreBtn.disabled = false;
|
$ignoreBtn.disabled = false;
|
||||||
|
|
@ -468,7 +547,8 @@ export async function renderSkuLinker($app) {
|
||||||
updateButtons();
|
updateButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
let tL = null, tR = null;
|
let tL = null,
|
||||||
|
tR = null;
|
||||||
$qL.addEventListener("input", () => {
|
$qL.addEventListener("input", () => {
|
||||||
if (tL) clearTimeout(tL);
|
if (tL) clearTimeout(tL);
|
||||||
tL = setTimeout(() => {
|
tL = setTimeout(() => {
|
||||||
|
|
@ -498,6 +578,10 @@ export async function renderSkuLinker($app) {
|
||||||
$status.textContent = "Not allowed: both sides cannot be the same SKU.";
|
$status.textContent = "Not allowed: both sides cannot be the same SKU.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (storesOverlap(pinnedL, pinnedR)) {
|
||||||
|
$status.textContent = "Not allowed: both items belong to the same store.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mappedSkus.has(a) || mappedSkus.has(b)) {
|
if (mappedSkus.has(a) || mappedSkus.has(b)) {
|
||||||
$status.textContent = "Not allowed: one of these SKUs is already mapped.";
|
$status.textContent = "Not allowed: one of these SKUs is already mapped.";
|
||||||
return;
|
return;
|
||||||
|
|
@ -507,11 +591,12 @@ export async function renderSkuLinker($app) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction: if either is BC-based (BCL/Strath appears), FROM is BC sku.
|
// Direction: if either is BC-based, FROM is BC sku.
|
||||||
const aBC = skuIsBC(allRows, a);
|
const aBC = skuIsBC(allRows, a);
|
||||||
const bBC = skuIsBC(allRows, b);
|
const bBC = skuIsBC(allRows, b);
|
||||||
|
|
||||||
let fromSku = a, toSku = b;
|
let fromSku = a,
|
||||||
|
toSku = b;
|
||||||
if (aBC && !bBC) {
|
if (aBC && !bBC) {
|
||||||
fromSku = a;
|
fromSku = a;
|
||||||
toSku = b;
|
toSku = b;
|
||||||
|
|
@ -549,6 +634,10 @@ export async function renderSkuLinker($app) {
|
||||||
$status.textContent = "Not allowed: both sides cannot be the same SKU.";
|
$status.textContent = "Not allowed: both sides cannot be the same SKU.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (storesOverlap(pinnedL, pinnedR)) {
|
||||||
|
$status.textContent = "Not allowed: both items belong to the same store.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isIgnoredPair(a, b)) {
|
if (isIgnoredPair(a, b)) {
|
||||||
$status.textContent = "This pair is already ignored.";
|
$status.textContent = "This pair is already ignored.";
|
||||||
return;
|
return;
|
||||||
|
|
@ -558,7 +647,6 @@ export async function renderSkuLinker($app) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const out = await apiWriteSkuIgnore(a, b);
|
const out = await apiWriteSkuIgnore(a, b);
|
||||||
// update in-memory ignore set
|
|
||||||
ignoreSet.add(rules.canonicalPairKey(a, b));
|
ignoreSet.add(rules.canonicalPairKey(a, b));
|
||||||
$status.textContent = `Ignored: ${displaySku(a)} × ${displaySku(b)} (ignores=${out.count}).`;
|
$status.textContent = `Ignored: ${displaySku(a)} × ${displaySku(b)} (ignores=${out.count}).`;
|
||||||
pinnedL = null;
|
pinnedL = null;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue