mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
sku updates
This commit is contained in:
parent
a154e7d284
commit
85838d26d9
2 changed files with 171 additions and 57 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
|
// viz/app/linker_page.js
|
||||||
import { esc, renderThumbHtml } from "./dom.js";
|
import { esc, renderThumbHtml } from "./dom.js";
|
||||||
import {
|
import {
|
||||||
tokenizeQuery,
|
tokenizeQuery,
|
||||||
matchesAllTokens,
|
matchesAllTokens,
|
||||||
isUnknownSkuKey,
|
|
||||||
displaySku,
|
displaySku,
|
||||||
keySkuForRow,
|
keySkuForRow,
|
||||||
normSearchText,
|
normSearchText,
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
apiWriteSkuLink,
|
apiWriteSkuLink,
|
||||||
apiWriteSkuIgnore,
|
apiWriteSkuIgnore,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { loadSkuRules } from "./mapping.js";
|
import { loadSkuRules, clearSkuRulesCache } from "./mapping.js";
|
||||||
|
|
||||||
/* ---------------- Similarity helpers ---------------- */
|
/* ---------------- Similarity helpers ---------------- */
|
||||||
|
|
||||||
|
|
@ -73,8 +73,8 @@ function similarityScore(aName, bName) {
|
||||||
const gate = firstMatch ? 1.0 : 0.12;
|
const gate = firstMatch ? 1.0 : 0.12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
firstMatch * 3.0 + // first word dominates
|
firstMatch * 3.0 + // first word dominates
|
||||||
overlapTail * 2.2 * gate + // tail matters mostly after first word match
|
overlapTail * 2.2 * gate + // tail matters mostly after first word match
|
||||||
levSim * (firstMatch ? 1.0 : 0.15) // edit-sim also mostly after first word match
|
levSim * (firstMatch ? 1.0 : 0.15) // edit-sim also mostly after first word match
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +110,6 @@ function fastSimilarityScore(aTokens, bTokens, aNormName, bNormName) {
|
||||||
return firstMatch * 2.4 + overlapTail * 2.0 * gate + pref;
|
return firstMatch * 2.4 + overlapTail * 2.0 * gate + pref;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ---------------- Store-overlap rule ---------------- */
|
/* ---------------- Store-overlap rule ---------------- */
|
||||||
|
|
||||||
function storesOverlap(aItem, bItem) {
|
function storesOverlap(aItem, bItem) {
|
||||||
|
|
@ -153,13 +152,89 @@ function skuIsBC(allRows, skuKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Canonical preference (AB real > other real > BC real > u:) ---------------- */
|
||||||
|
|
||||||
|
function isRealSkuKey(skuKey) {
|
||||||
|
return !String(skuKey || "").startsWith("u:");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isABStoreLabel(label) {
|
||||||
|
const s = String(label || "").toLowerCase();
|
||||||
|
// heuristic: tune as needed for your dataset
|
||||||
|
return (
|
||||||
|
s.includes("alberta") ||
|
||||||
|
s.includes("calgary") ||
|
||||||
|
s.includes("edmonton") ||
|
||||||
|
/\bab\b/.test(s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function skuIsAB(allRows, skuKey) {
|
||||||
|
for (const r of allRows) {
|
||||||
|
if (keySkuForRow(r) !== skuKey) continue;
|
||||||
|
const lab = String(r.storeLabel || r.store || "");
|
||||||
|
if (isABStoreLabel(lab)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCanonical(allRows, skuKey) {
|
||||||
|
const s = String(skuKey || "");
|
||||||
|
const real = isRealSkuKey(s) ? 1 : 0;
|
||||||
|
const ab = skuIsAB(allRows, s) ? 1 : 0;
|
||||||
|
const bc = skuIsBC(allRows, s) ? 1 : 0;
|
||||||
|
|
||||||
|
// Prefer: real AB > real non-BC > real BC > u:
|
||||||
|
return real * 100 + ab * 25 - bc * 10 + (real ? 0 : -1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPreferredCanonical(allRows, skuKeys) {
|
||||||
|
let best = "";
|
||||||
|
let bestScore = -Infinity;
|
||||||
|
|
||||||
|
for (const k of skuKeys) {
|
||||||
|
const s = String(k || "").trim();
|
||||||
|
if (!s) continue;
|
||||||
|
const sc = scoreCanonical(allRows, s);
|
||||||
|
if (sc > bestScore) {
|
||||||
|
bestScore = sc;
|
||||||
|
best = s;
|
||||||
|
} else if (sc === bestScore && s && best && s < best) {
|
||||||
|
best = s; // stable tie-break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Randomization helpers (avoid same suggestion subset) ---------------- */
|
||||||
|
|
||||||
|
function mulberry32(seed) {
|
||||||
|
let t = seed >>> 0;
|
||||||
|
return function () {
|
||||||
|
t += 0x6d2b79f5;
|
||||||
|
let x = Math.imul(t ^ (t >>> 15), 1 | t);
|
||||||
|
x ^= x + Math.imul(x ^ (x >>> 7), 61 | x);
|
||||||
|
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleInPlace(arr, rnd) {
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = (rnd() * (i + 1)) | 0;
|
||||||
|
const tmp = arr[i];
|
||||||
|
arr[i] = arr[j];
|
||||||
|
arr[j] = tmp;
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- Suggestion helpers ---------------- */
|
/* ---------------- Suggestion helpers ---------------- */
|
||||||
|
|
||||||
function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) {
|
function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) {
|
||||||
const scored = [];
|
const scored = [];
|
||||||
for (const it of allAgg) {
|
for (const it of allAgg) {
|
||||||
if (!it) continue;
|
if (!it) continue;
|
||||||
if (isUnknownSkuKey(it.sku)) continue;
|
|
||||||
if (mappedSkus && mappedSkus.has(String(it.sku))) continue;
|
if (mappedSkus && mappedSkus.has(String(it.sku))) continue;
|
||||||
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
||||||
|
|
||||||
|
|
@ -189,7 +264,6 @@ function recommendSimilar(
|
||||||
|
|
||||||
for (const it of allAgg) {
|
for (const it of allAgg) {
|
||||||
if (!it) continue;
|
if (!it) continue;
|
||||||
if (isUnknownSkuKey(it.sku)) continue;
|
|
||||||
if (mappedSkus && mappedSkus.has(String(it.sku))) continue;
|
if (mappedSkus && mappedSkus.has(String(it.sku))) continue;
|
||||||
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;
|
||||||
|
|
@ -212,21 +286,32 @@ function recommendSimilar(
|
||||||
|
|
||||||
// FAST initial pairing (approx) with ignore-pair exclusion + same-store exclusion
|
// FAST initial pairing (approx) with ignore-pair exclusion + same-store exclusion
|
||||||
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) {
|
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) {
|
||||||
|
// Only exclude already-linked SKUs from auto-suggestions
|
||||||
const items = allAgg.filter((it) => {
|
const items = allAgg.filter((it) => {
|
||||||
if (!it) return false;
|
if (!it) return false;
|
||||||
if (isUnknownSkuKey(it.sku)) return false;
|
|
||||||
if (mappedSkus && mappedSkus.has(String(it.sku))) return false;
|
if (mappedSkus && mappedSkus.has(String(it.sku))) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const seeds = topSuggestions(items, Math.min(220, items.length), "", mappedSkus);
|
// randomize the "subset" each load so we don't get stuck in the same chunk
|
||||||
|
const seed = (Date.now() ^ ((Math.random() * 1e9) | 0)) >>> 0;
|
||||||
|
const rnd = mulberry32(seed);
|
||||||
|
|
||||||
|
const itemsShuf = items.slice();
|
||||||
|
shuffleInPlace(itemsShuf, rnd);
|
||||||
|
|
||||||
|
// sample a bounded working set for speed
|
||||||
|
const WORK_CAP = 1400;
|
||||||
|
const work = itemsShuf.length > WORK_CAP ? itemsShuf.slice(0, WORK_CAP) : itemsShuf;
|
||||||
|
|
||||||
|
const seeds = topSuggestions(work, Math.min(220, work.length), "", mappedSkus);
|
||||||
|
|
||||||
const TOKEN_BUCKET_CAP = 180;
|
const TOKEN_BUCKET_CAP = 180;
|
||||||
const tokMap = new Map();
|
const tokMap = new Map();
|
||||||
const itemTokens = new Map();
|
const itemTokens = new Map();
|
||||||
const itemNormName = new Map();
|
const itemNormName = new Map();
|
||||||
|
|
||||||
for (const it of items) {
|
for (const it of work) {
|
||||||
const toks = Array.from(new Set(tokenizeQuery(it.name || "")))
|
const toks = Array.from(new Set(tokenizeQuery(it.name || "")))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
@ -259,7 +344,6 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
const bSku = String(b.sku || "");
|
const bSku = String(b.sku || "");
|
||||||
if (!bSku || bSku === aSku) continue;
|
if (!bSku || bSku === aSku) continue;
|
||||||
if (mappedSkus && mappedSkus.has(bSku)) continue;
|
if (mappedSkus && mappedSkus.has(bSku)) continue;
|
||||||
if (isUnknownSkuKey(bSku)) continue;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof isIgnoredPairFn === "function" &&
|
typeof isIgnoredPairFn === "function" &&
|
||||||
|
|
@ -332,7 +416,7 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
|
|
||||||
export async function renderSkuLinker($app) {
|
export async function renderSkuLinker($app) {
|
||||||
const localWrite = isLocalWriteMode();
|
const localWrite = isLocalWriteMode();
|
||||||
const rules = await loadSkuRules();
|
let rules = await loadSkuRules();
|
||||||
|
|
||||||
$app.innerHTML = `
|
$app.innerHTML = `
|
||||||
<div class="container" style="max-width:1200px;">
|
<div class="container" style="max-width:1200px;">
|
||||||
|
|
@ -345,7 +429,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. Same-store pairs are never suggested. LINK SKU writes map; IGNORE PAIR writes a "do-not-suggest" pair (local only).
|
Existing mapped SKUs are excluded from auto-suggestions. Same-store pairs are never suggested. LINK SKU writes map (can merge groups); IGNORE PAIR writes a "do-not-suggest" pair (local only).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; gap:16px;">
|
<div style="display:flex; gap:16px;">
|
||||||
|
|
@ -403,12 +487,12 @@ export async function renderSkuLinker($app) {
|
||||||
if (!m.has(storeLabel)) m.set(storeLabel, url);
|
if (!m.has(storeLabel)) m.set(storeLabel, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// candidates for this page (hide unknown u: entirely)
|
// candidates for this page (allow u: so KegNCork can be linked)
|
||||||
const allAgg = aggregateBySku(allRows, (x) => x).filter((it) => !isUnknownSkuKey(it.sku));
|
const allAgg = aggregateBySku(allRows, (x) => x);
|
||||||
|
|
||||||
const meta = await loadSkuMetaBestEffort();
|
const meta = await loadSkuMetaBestEffort();
|
||||||
const mappedSkus = buildMappedSkuSet(meta.links || []);
|
const mappedSkus = buildMappedSkuSet(meta.links || []);
|
||||||
const ignoreSet = rules.ignoreSet; // already canonicalized as "a|b"
|
let ignoreSet = rules.ignoreSet; // already canonicalized as "a|b"
|
||||||
|
|
||||||
function isIgnoredPair(a, b) {
|
function isIgnoredPair(a, b) {
|
||||||
return rules.isIgnoredPair(String(a || ""), String(b || ""));
|
return rules.isIgnoredPair(String(a || ""), String(b || ""));
|
||||||
|
|
@ -465,13 +549,13 @@ export async function renderSkuLinker($app) {
|
||||||
const tokens = tokenizeQuery(query);
|
const tokens = tokenizeQuery(query);
|
||||||
const otherSku = otherPinned ? String(otherPinned.sku || "") : "";
|
const otherSku = otherPinned ? String(otherPinned.sku || "") : "";
|
||||||
|
|
||||||
|
// manual search: allow mapped SKUs so you can merge groups
|
||||||
if (tokens.length) {
|
if (tokens.length) {
|
||||||
let out = allAgg
|
let out = allAgg
|
||||||
.filter(
|
.filter(
|
||||||
(it) =>
|
(it) =>
|
||||||
it &&
|
it &&
|
||||||
it.sku !== otherSku &&
|
it.sku !== otherSku &&
|
||||||
!mappedSkus.has(String(it.sku)) &&
|
|
||||||
matchesAllTokens(it.searchText, tokens)
|
matchesAllTokens(it.searchText, tokens)
|
||||||
)
|
)
|
||||||
.slice(0, 80);
|
.slice(0, 80);
|
||||||
|
|
@ -484,6 +568,7 @@ export async function renderSkuLinker($app) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auto-suggestions: never include mapped skus
|
||||||
if (otherPinned) return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus, isIgnoredPair);
|
if (otherPinned) return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus, isIgnoredPair);
|
||||||
|
|
||||||
if (initialPairs && initialPairs.length) {
|
if (initialPairs && initialPairs.length) {
|
||||||
|
|
@ -501,13 +586,6 @@ export async function renderSkuLinker($app) {
|
||||||
const it = allAgg.find((x) => String(x.sku || "") === skuKey);
|
const it = allAgg.find((x) => String(x.sku || "") === skuKey);
|
||||||
if (!it) return;
|
if (!it) return;
|
||||||
|
|
||||||
if (isUnknownSkuKey(it.sku)) return;
|
|
||||||
|
|
||||||
if (mappedSkus.has(String(it.sku))) {
|
|
||||||
$status.textContent = "This SKU is already mapped; choose an unmapped SKU.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 || "")) {
|
||||||
|
|
@ -579,13 +657,9 @@ export async function renderSkuLinker($app) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mappedSkus.has(a) || mappedSkus.has(b)) {
|
// link is allowed even if either sku is already in a link (merges groups)
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.disabled = false;
|
||||||
$ignoreBtn.disabled = false;
|
$ignoreBtn.disabled = false;
|
||||||
} else {
|
|
||||||
$linkBtn.disabled = false;
|
|
||||||
$ignoreBtn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isIgnoredPair(a, b)) {
|
if (isIgnoredPair(a, b)) {
|
||||||
$status.textContent = "This pair is already ignored.";
|
$status.textContent = "This pair is already ignored.";
|
||||||
|
|
@ -622,8 +696,8 @@ export async function renderSkuLinker($app) {
|
||||||
const a = String(pinnedL.sku || "");
|
const a = String(pinnedL.sku || "");
|
||||||
const b = String(pinnedR.sku || "");
|
const b = String(pinnedR.sku || "");
|
||||||
|
|
||||||
if (!a || !b || isUnknownSkuKey(a) || isUnknownSkuKey(b)) {
|
if (!a || !b) {
|
||||||
$status.textContent = "Not allowed: unknown SKUs cannot be linked.";
|
$status.textContent = "Not allowed: missing SKU.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (a === b) {
|
if (a === b) {
|
||||||
|
|
@ -634,35 +708,72 @@ export async function renderSkuLinker($app) {
|
||||||
$status.textContent = "Not allowed: both items belong to the same store.";
|
$status.textContent = "Not allowed: both items belong to the same store.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mappedSkus.has(a) || mappedSkus.has(b)) {
|
|
||||||
$status.textContent = "Not allowed: one of these SKUs is already mapped.";
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction: if either is BC-based, FROM is BC sku.
|
// Determine current group canonicals (if already linked)
|
||||||
const aBC = skuIsBC(allRows, a);
|
const aCanon = rules.canonicalSku(a);
|
||||||
const bBC = skuIsBC(allRows, b);
|
const bCanon = rules.canonicalSku(b);
|
||||||
|
|
||||||
let fromSku = a, toSku = b;
|
// Choose canonical to render/group by: prefer Alberta real, never BC if avoidable, never u: if any real exists
|
||||||
if (aBC && !bBC) {
|
const preferred = pickPreferredCanonical(allRows, [a, b, aCanon, bCanon]);
|
||||||
fromSku = a;
|
|
||||||
toSku = b;
|
if (!preferred) {
|
||||||
} else if (bBC && !aBC) {
|
$status.textContent = "Write failed: could not choose a canonical SKU.";
|
||||||
fromSku = b;
|
return;
|
||||||
toSku = a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$status.textContent = `Writing: ${displaySku(fromSku)} → ${displaySku(toSku)} …`;
|
// Build minimal writes to merge everything under `preferred`
|
||||||
|
const writes = [];
|
||||||
|
function addWrite(fromSku, toSku) {
|
||||||
|
const f = String(fromSku || "").trim();
|
||||||
|
const t = String(toSku || "").trim();
|
||||||
|
if (!f || !t || f === t) return;
|
||||||
|
if (rules.canonicalSku(f) === t) return; // already resolves to target
|
||||||
|
writes.push({ fromSku: f, toSku: t });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge existing groups (if their canonicals differ)
|
||||||
|
addWrite(aCanon, preferred);
|
||||||
|
addWrite(bCanon, preferred);
|
||||||
|
|
||||||
|
// Ensure the pinned SKUs end up in the preferred group too
|
||||||
|
addWrite(a, preferred);
|
||||||
|
addWrite(b, preferred);
|
||||||
|
|
||||||
|
// de-dupe
|
||||||
|
const seenW = new Set();
|
||||||
|
const uniq = [];
|
||||||
|
for (const w of writes) {
|
||||||
|
const k = `${w.fromSku}→${w.toSku}`;
|
||||||
|
if (seenW.has(k)) continue;
|
||||||
|
seenW.add(k);
|
||||||
|
uniq.push(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status.textContent = `Writing ${uniq.length} link(s) to canonical ${displaySku(preferred)} …`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const out = await apiWriteSkuLink(fromSku, toSku);
|
for (let i = 0; i < uniq.length; i++) {
|
||||||
mappedSkus.add(fromSku);
|
const w = uniq[i];
|
||||||
mappedSkus.add(toSku);
|
$status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku(w.fromSku)} → ${displaySku(w.toSku)} …`;
|
||||||
$status.textContent = `Saved: ${displaySku(fromSku)} → ${displaySku(toSku)} (links=${out.count}).`;
|
await apiWriteSkuLink(w.fromSku, w.toSku);
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh rules/meta in-memory
|
||||||
|
clearSkuRulesCache();
|
||||||
|
rules = await loadSkuRules();
|
||||||
|
ignoreSet = rules.ignoreSet;
|
||||||
|
|
||||||
|
// rebuild mapped set from updated links
|
||||||
|
const meta2 = await loadSkuMetaBestEffort();
|
||||||
|
const rebuilt = buildMappedSkuSet(meta2?.links || []);
|
||||||
|
mappedSkus.clear();
|
||||||
|
for (const x of rebuilt) mappedSkus.add(x);
|
||||||
|
|
||||||
|
$status.textContent = `Saved. Canonical is now ${displaySku(preferred)}.`;
|
||||||
pinnedL = null;
|
pinnedL = null;
|
||||||
pinnedR = null;
|
pinnedR = null;
|
||||||
updateAll();
|
updateAll();
|
||||||
|
|
@ -677,8 +788,8 @@ export async function renderSkuLinker($app) {
|
||||||
const a = String(pinnedL.sku || "");
|
const a = String(pinnedL.sku || "");
|
||||||
const b = String(pinnedR.sku || "");
|
const b = String(pinnedR.sku || "");
|
||||||
|
|
||||||
if (!a || !b || isUnknownSkuKey(a) || isUnknownSkuKey(b)) {
|
if (!a || !b) {
|
||||||
$status.textContent = "Not allowed: unknown SKUs cannot be ignored.";
|
$status.textContent = "Not allowed: missing SKU.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (a === b) {
|
if (a === b) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
// viz/app/mapping.js
|
||||||
import { loadSkuMetaBestEffort } from "./api.js";
|
import { loadSkuMetaBestEffort } from "./api.js";
|
||||||
|
|
||||||
let CACHED = null;
|
let CACHED = null;
|
||||||
|
|
||||||
|
export function clearSkuRulesCache() {
|
||||||
|
CACHED = null;
|
||||||
|
}
|
||||||
|
|
||||||
function canonicalPairKey(a, b) {
|
function canonicalPairKey(a, b) {
|
||||||
const x = String(a || "");
|
const x = String(a || "");
|
||||||
const y = String(b || "");
|
const y = String(b || "");
|
||||||
|
|
@ -23,8 +28,7 @@ function resolveSkuWithMap(sku, forwardMap) {
|
||||||
const s0 = String(sku || "").trim();
|
const s0 = String(sku || "").trim();
|
||||||
if (!s0) return s0;
|
if (!s0) return s0;
|
||||||
|
|
||||||
// Only resolve real SKUs; leave synthetic u: alone
|
// NOTE: u: keys are allowed to resolve through the map (so unknowns can be grouped)
|
||||||
if (s0.startsWith("u:")) return s0;
|
|
||||||
|
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
let cur = s0;
|
let cur = s0;
|
||||||
|
|
@ -53,7 +57,6 @@ function buildToGroups(links, forwardMap) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// close transitively: any sku that resolves to canonTo belongs in its group
|
// close transitively: any sku that resolves to canonTo belongs in its group
|
||||||
// (cheap pass: expand by resolving all known skus in current link set)
|
|
||||||
const allSkus = new Set();
|
const allSkus = new Set();
|
||||||
for (const x of Array.isArray(links) ? links : []) {
|
for (const x of Array.isArray(links) ? links : []) {
|
||||||
const a = String(x?.fromSku || "").trim();
|
const a = String(x?.fromSku || "").trim();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue