mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
link sku
This commit is contained in:
parent
f09416aae9
commit
1e4b24d391
1 changed files with 87 additions and 16 deletions
|
|
@ -416,6 +416,48 @@ function storesOverlap(aItem, bItem) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Canonical-group store cache (FAST) ---------------- */
|
||||||
|
|
||||||
|
// canonSku -> Set<storeLabel>
|
||||||
|
let CANON_STORE_CACHE = new Map();
|
||||||
|
|
||||||
|
function buildCanonStoreCache(allAgg, rules) {
|
||||||
|
const m = new Map();
|
||||||
|
for (const it of allAgg) {
|
||||||
|
if (!it) continue;
|
||||||
|
|
||||||
|
const skuKey = String(it.sku || "").trim();
|
||||||
|
if (!skuKey) continue;
|
||||||
|
|
||||||
|
const canon = String(rules.canonicalSku(skuKey) || skuKey);
|
||||||
|
let set = m.get(canon);
|
||||||
|
if (!set) m.set(canon, (set = new Set()));
|
||||||
|
|
||||||
|
const stores = it.stores;
|
||||||
|
if (stores && stores.size) for (const s of stores) set.add(s);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonKeyForSku(rules, skuKey) {
|
||||||
|
const s = String(skuKey || "").trim();
|
||||||
|
if (!s) return "";
|
||||||
|
return String(rules.canonicalSku(s) || s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonStoresForSku(rules, skuKey) {
|
||||||
|
const canon = canonKeyForSku(rules, skuKey);
|
||||||
|
return canon ? CANON_STORE_CACHE.get(canon) || new Set() : new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonStoresOverlapSku(rules, aSku, bSku) {
|
||||||
|
const A = canonStoresForSku(rules, aSku);
|
||||||
|
const B = canonStoresForSku(rules, bSku);
|
||||||
|
if (!A.size || !B.size) return false;
|
||||||
|
for (const s of A) if (B.has(s)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- Mapping helpers ---------------- */
|
/* ---------------- Mapping helpers ---------------- */
|
||||||
|
|
||||||
function buildMappedSkuSet(links, rules) {
|
function buildMappedSkuSet(links, rules) {
|
||||||
|
|
@ -577,7 +619,7 @@ function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) {
|
||||||
|
|
||||||
// IMPORTANT behavior guarantees:
|
// IMPORTANT behavior guarantees:
|
||||||
// - NEVER fully blocks based on "brand"/first-token mismatch.
|
// - NEVER fully blocks based on "brand"/first-token mismatch.
|
||||||
// - ONLY hard-blocks: same-store overlap, ignored pair, already-linked (same canonical group), otherPinnedSku, self.
|
// - ONLY hard-blocks: same-store overlap (by canonical-group), ignored pair, already-linked (same canonical group), otherPinnedSku, self.
|
||||||
// - If scoring gets too strict, it falls back to a "least-bad" list (still respecting hard blocks).
|
// - If scoring gets too strict, it falls back to a "least-bad" list (still respecting hard blocks).
|
||||||
function recommendSimilar(
|
function recommendSimilar(
|
||||||
allAgg,
|
allAgg,
|
||||||
|
|
@ -587,6 +629,7 @@ function recommendSimilar(
|
||||||
mappedSkus,
|
mappedSkus,
|
||||||
isIgnoredPairFn,
|
isIgnoredPairFn,
|
||||||
sizePenaltyFn,
|
sizePenaltyFn,
|
||||||
|
sameStoreFn, // (aSku, bSku) => bool ✅ CHANGED
|
||||||
sameGroupFn
|
sameGroupFn
|
||||||
) {
|
) {
|
||||||
if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus);
|
if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus);
|
||||||
|
|
@ -630,7 +673,7 @@ function recommendSimilar(
|
||||||
if (otherSku && itSku === otherSku) continue;
|
if (otherSku && itSku === otherSku) continue;
|
||||||
|
|
||||||
// HARD BLOCKS ONLY:
|
// HARD BLOCKS ONLY:
|
||||||
if (storesOverlap(pinned, it)) continue;
|
if (typeof sameStoreFn === "function" && sameStoreFn(pinnedSku, itSku)) continue; // ✅ CHANGED
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, itSku)) continue;
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, itSku)) continue;
|
||||||
if (typeof sameGroupFn === "function" && sameGroupFn(pinnedSku, itSku)) continue;
|
if (typeof sameGroupFn === "function" && sameGroupFn(pinnedSku, itSku)) continue;
|
||||||
|
|
||||||
|
|
@ -747,7 +790,7 @@ function recommendSimilar(
|
||||||
if (itSku === pinnedSku) continue;
|
if (itSku === pinnedSku) continue;
|
||||||
if (otherSku && itSku === otherSku) continue;
|
if (otherSku && itSku === otherSku) continue;
|
||||||
|
|
||||||
if (storesOverlap(pinned, it)) continue;
|
if (typeof sameStoreFn === "function" && sameStoreFn(pinnedSku, itSku)) continue; // ✅ CHANGED
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, itSku)) continue;
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, itSku)) continue;
|
||||||
if (typeof sameGroupFn === "function" && sameGroupFn(pinnedSku, itSku)) continue;
|
if (typeof sameGroupFn === "function" && sameGroupFn(pinnedSku, itSku)) continue;
|
||||||
|
|
||||||
|
|
@ -763,7 +806,7 @@ function recommendSimilar(
|
||||||
return fallback.slice(0, limit).map((x) => x.it);
|
return fallback.slice(0, limit).map((x) => x.it);
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) {
|
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn, sameStoreFn) { // ✅ CHANGED
|
||||||
const itemsAll = allAgg.filter((it) => !!it);
|
const itemsAll = allAgg.filter((it) => !!it);
|
||||||
|
|
||||||
const seed = (Date.now() ^ ((Math.random() * 1e9) | 0)) >>> 0;
|
const seed = (Date.now() ^ ((Math.random() * 1e9) | 0)) >>> 0;
|
||||||
|
|
@ -838,7 +881,7 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
const aSku = String(a.sku || "");
|
const aSku = String(a.sku || "");
|
||||||
const bSku = String(b.sku || "");
|
const bSku = String(b.sku || "");
|
||||||
if (!aSku || !bSku || aSku === bSku) continue;
|
if (!aSku || !bSku || aSku === bSku) continue;
|
||||||
if (storesOverlap(a, b)) continue;
|
if (typeof sameStoreFn === "function" && sameStoreFn(aSku, bSku)) continue; // ✅ CHANGED
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
||||||
|
|
||||||
const s = 1e9 + itemRank(a) + itemRank(b);
|
const s = 1e9 + itemRank(a) + itemRank(b);
|
||||||
|
|
@ -934,7 +977,7 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
if (mappedSkus && mappedSkus.has(bSku)) continue;
|
if (mappedSkus && mappedSkus.has(bSku)) continue;
|
||||||
|
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
||||||
if (storesOverlap(a, b)) continue;
|
if (typeof sameStoreFn === "function" && sameStoreFn(aSku, bSku)) continue; // ✅ CHANGED
|
||||||
|
|
||||||
cand.set(bSku, b);
|
cand.set(bSku, b);
|
||||||
}
|
}
|
||||||
|
|
@ -996,7 +1039,7 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
const bSku = String(p.b.sku || "");
|
const bSku = String(p.b.sku || "");
|
||||||
if (!aSku || !bSku || aSku === bSku) return false;
|
if (!aSku || !bSku || aSku === bSku) return false;
|
||||||
if (used.has(aSku) || used.has(bSku)) return false;
|
if (used.has(aSku) || used.has(bSku)) return false;
|
||||||
if (storesOverlap(p.a, p.b)) return false;
|
if (typeof sameStoreFn === "function" && sameStoreFn(aSku, bSku)) return false; // ✅ CHANGED
|
||||||
|
|
||||||
used.add(aSku);
|
used.add(aSku);
|
||||||
used.add(bSku);
|
used.add(bSku);
|
||||||
|
|
@ -1144,6 +1187,14 @@ export async function renderSkuLinker($app) {
|
||||||
const mappedSkus = buildMappedSkuSet(meta.links || [], rules);
|
const mappedSkus = buildMappedSkuSet(meta.links || [], rules);
|
||||||
let ignoreSet = rules.ignoreSet;
|
let ignoreSet = rules.ignoreSet;
|
||||||
|
|
||||||
|
// ✅ NEW: build canonical-group store cache (used for hard blocking store duplicates)
|
||||||
|
CANON_STORE_CACHE = buildCanonStoreCache(allAgg, rules);
|
||||||
|
|
||||||
|
// Helper uses current rules + cache
|
||||||
|
function sameStoreCanon(aSku, bSku) {
|
||||||
|
return canonStoresOverlapSku(rules, String(aSku || ""), String(bSku || ""));
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- Canonical-group size cache (FAST) ---------------- */
|
/* ---------------- Canonical-group size cache (FAST) ---------------- */
|
||||||
|
|
||||||
// skuKey -> Set<int ml>
|
// skuKey -> Set<int ml>
|
||||||
|
|
@ -1217,7 +1268,7 @@ export async function renderSkuLinker($app) {
|
||||||
return String(rules.canonicalSku(aSku)) === String(rules.canonicalSku(bSku));
|
return String(rules.canonicalSku(aSku)) === String(rules.canonicalSku(bSku));
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialPairs = computeInitialPairsFast(allAgg, mappedSkus, 28, isIgnoredPair);
|
const initialPairs = computeInitialPairsFast(allAgg, mappedSkus, 28, isIgnoredPair, sameStoreCanon); // ✅ CHANGED
|
||||||
|
|
||||||
let pinnedL = null;
|
let pinnedL = null;
|
||||||
let pinnedR = null;
|
let pinnedR = null;
|
||||||
|
|
@ -1278,7 +1329,7 @@ export async function renderSkuLinker($app) {
|
||||||
if (otherPinned) {
|
if (otherPinned) {
|
||||||
const oSku = String(otherPinned.sku || "");
|
const oSku = String(otherPinned.sku || "");
|
||||||
out = out.filter((it) => !isIgnoredPair(oSku, String(it.sku || "")));
|
out = out.filter((it) => !isIgnoredPair(oSku, String(it.sku || "")));
|
||||||
out = out.filter((it) => !storesOverlap(otherPinned, it));
|
out = out.filter((it) => !sameStoreCanon(oSku, String(it.sku || ""))); // ✅ CHANGED
|
||||||
out = out.filter((it) => !sameGroup(oSku, String(it.sku || "")));
|
out = out.filter((it) => !sameGroup(oSku, String(it.sku || "")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1295,6 +1346,7 @@ export async function renderSkuLinker($app) {
|
||||||
mappedSkus,
|
mappedSkus,
|
||||||
isIgnoredPair,
|
isIgnoredPair,
|
||||||
sizePenaltyForPair,
|
sizePenaltyForPair,
|
||||||
|
sameStoreCanon, // ✅ CHANGED
|
||||||
sameGroup
|
sameGroup
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1325,8 +1377,8 @@ export async function renderSkuLinker($app) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HARD BLOCK: store overlap (per your requirement)
|
// HARD BLOCK: store overlap (canonical-group) ✅ CHANGED
|
||||||
if (other && storesOverlap(other, it)) {
|
if (other && sameStoreCanon(String(other.sku || ""), String(it.sku || ""))) {
|
||||||
$status.textContent = "Not allowed: both items belong to the same store.";
|
$status.textContent = "Not allowed: both items belong to the same store.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1407,8 +1459,8 @@ export async function renderSkuLinker($app) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HARD BLOCK: store overlap
|
// HARD BLOCK: store overlap (canonical-group) ✅ CHANGED
|
||||||
if (storesOverlap(pinnedL, pinnedR)) {
|
if (sameStoreCanon(a, b)) {
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.disabled = true;
|
||||||
$ignoreBtn.disabled = true;
|
$ignoreBtn.disabled = true;
|
||||||
$status.textContent = "Not allowed: both items belong to the same store.";
|
$status.textContent = "Not allowed: both items belong to the same store.";
|
||||||
|
|
@ -1449,6 +1501,9 @@ export async function renderSkuLinker($app) {
|
||||||
rules = await loadSkuRules();
|
rules = await loadSkuRules();
|
||||||
ignoreSet = rules.ignoreSet;
|
ignoreSet = rules.ignoreSet;
|
||||||
|
|
||||||
|
// ✅ NEW: rebuild canonical-store cache after rules reload
|
||||||
|
CANON_STORE_CACHE = buildCanonStoreCache(allAgg, rules);
|
||||||
|
|
||||||
const rebuilt = buildMappedSkuSet(rules.links || [], rules);
|
const rebuilt = buildMappedSkuSet(rules.links || [], rules);
|
||||||
mappedSkus.clear();
|
mappedSkus.clear();
|
||||||
for (const x of rebuilt) mappedSkus.add(x);
|
for (const x of rebuilt) mappedSkus.add(x);
|
||||||
|
|
@ -1506,6 +1561,9 @@ export async function renderSkuLinker($app) {
|
||||||
rules = await loadSkuRules();
|
rules = await loadSkuRules();
|
||||||
ignoreSet = rules.ignoreSet;
|
ignoreSet = rules.ignoreSet;
|
||||||
|
|
||||||
|
// ✅ NEW: rebuild canonical-store cache after rules reload
|
||||||
|
CANON_STORE_CACHE = buildCanonStoreCache(allAgg, rules);
|
||||||
|
|
||||||
const rebuilt = buildMappedSkuSet(rules.links || [], rules);
|
const rebuilt = buildMappedSkuSet(rules.links || [], rules);
|
||||||
mappedSkus.clear();
|
mappedSkus.clear();
|
||||||
for (const x of rebuilt) mappedSkus.add(x);
|
for (const x of rebuilt) mappedSkus.add(x);
|
||||||
|
|
@ -1602,7 +1660,8 @@ 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)) {
|
// HARD BLOCK: store overlap (canonical-group) ✅ CHANGED
|
||||||
|
if (sameStoreCanon(a, b)) {
|
||||||
$status.textContent = "Not allowed: both items belong to the same store.";
|
$status.textContent = "Not allowed: both items belong to the same store.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1654,6 +1713,9 @@ export async function renderSkuLinker($app) {
|
||||||
rules = await loadSkuRules();
|
rules = await loadSkuRules();
|
||||||
ignoreSet = rules.ignoreSet;
|
ignoreSet = rules.ignoreSet;
|
||||||
|
|
||||||
|
// ✅ NEW: rebuild canonical-store cache after rules reload
|
||||||
|
CANON_STORE_CACHE = buildCanonStoreCache(allAgg, rules);
|
||||||
|
|
||||||
const rebuilt = buildMappedSkuSet(rules.links || [], rules);
|
const rebuilt = buildMappedSkuSet(rules.links || [], rules);
|
||||||
mappedSkus.clear();
|
mappedSkus.clear();
|
||||||
for (const x of rebuilt) mappedSkus.add(x);
|
for (const x of rebuilt) mappedSkus.add(x);
|
||||||
|
|
@ -1689,8 +1751,11 @@ export async function renderSkuLinker($app) {
|
||||||
rules = await loadSkuRules();
|
rules = await loadSkuRules();
|
||||||
ignoreSet = rules.ignoreSet;
|
ignoreSet = rules.ignoreSet;
|
||||||
|
|
||||||
|
// ✅ NEW: rebuild canonical-store cache after rules reload
|
||||||
|
CANON_STORE_CACHE = buildCanonStoreCache(allAgg, rules);
|
||||||
|
|
||||||
const meta2 = await loadSkuMetaBestEffort();
|
const meta2 = await loadSkuMetaBestEffort();
|
||||||
const rebuilt = buildMappedSkuSet(meta2?.links || []);
|
const rebuilt = buildMappedSkuSet(meta2?.links || [], rules);
|
||||||
mappedSkus.clear();
|
mappedSkus.clear();
|
||||||
for (const x of rebuilt) mappedSkus.add(x);
|
for (const x of rebuilt) mappedSkus.add(x);
|
||||||
|
|
||||||
|
|
@ -1720,7 +1785,8 @@ 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)) {
|
// HARD BLOCK: store overlap (canonical-group) ✅ CHANGED
|
||||||
|
if (sameStoreCanon(a, b)) {
|
||||||
$status.textContent = "Not allowed: both items belong to the same store.";
|
$status.textContent = "Not allowed: both items belong to the same store.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1742,6 +1808,9 @@ export async function renderSkuLinker($app) {
|
||||||
rules = await loadSkuRules();
|
rules = await loadSkuRules();
|
||||||
ignoreSet = rules.ignoreSet;
|
ignoreSet = rules.ignoreSet;
|
||||||
|
|
||||||
|
// ✅ NEW: rebuild canonical-store cache after rules reload
|
||||||
|
CANON_STORE_CACHE = buildCanonStoreCache(allAgg, rules);
|
||||||
|
|
||||||
const rebuilt = buildMappedSkuSet(rules.links || [], rules);
|
const rebuilt = buildMappedSkuSet(rules.links || [], rules);
|
||||||
mappedSkus.clear();
|
mappedSkus.clear();
|
||||||
for (const x of rebuilt) mappedSkus.add(x);
|
for (const x of rebuilt) mappedSkus.add(x);
|
||||||
|
|
@ -1766,6 +1835,8 @@ export async function renderSkuLinker($app) {
|
||||||
$status.textContent = `Ignored: ${displaySku(a)} × ${displaySku(b)} (ignores=${out.count}).`;
|
$status.textContent = `Ignored: ${displaySku(a)} × ${displaySku(b)} (ignores=${out.count}).`;
|
||||||
pinnedL = null;
|
pinnedL = null;
|
||||||
pinnedR = null;
|
pinnedR = null;
|
||||||
|
|
||||||
|
// (rules not reloaded here, but if ignore changes canonical behavior in your rules impl, you can reload)
|
||||||
updateAll();
|
updateAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$status.textContent = `Ignore failed: ${String(e && e.message ? e.message : e)}`;
|
$status.textContent = `Ignore failed: ${String(e && e.message ? e.message : e)}`;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue