[object Object]

← back to Designer Wallcoverings

TK-11400: fix false-success in inventory-set-2026-newest sweep

e252ac31be5bd7c22a7ae5efe5a353e4a0d371e1 · 2026-09-10 12:53:26 -0700 · Steve

Root cause: newest priced variants are tracked but NOT activated (stocked) at
the Ventura Blvd location, so inventorySetQuantities silently no-ops (0
userErrors, writes nothing). The script never called inventoryActivate, and
counted "batch had no userErrors" as success (setOk += chunk.length - ue.length,
also treating a null data payload as OK), so it logged "~2000 ok, 0 errors"
while its own re-scan showed 0 landed — and still exited 0, so the hourly job
logged success.

Fix (mirrors the proven sweep-all-active.mjs activate-then-set pattern):
- add inventoryActivate for every not-stocked item before the on_hand set
- honest accounting: relabel the write count "submitted", treat null data as
  an error, never as OK
- race-free targeted VERIFY: re-read the exact inventoryItem ids written (by id)
  instead of a fresh newest-N scan that can shift between scan and verify
- exit non-zero when the verify shows anything still wrong, so the hourly job
  and canaries SEE the failure instead of logging false success

Scan-time $0 guard is unchanged, so activation at QTY cannot re-inflate a
$0/quote-only variant (TK-11357).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e252ac31be5bd7c22a7ae5efe5a353e4a0d371e1
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 10 12:53:26 2026 -0700

    TK-11400: fix false-success in inventory-set-2026-newest sweep
    
    Root cause: newest priced variants are tracked but NOT activated (stocked) at
    the Ventura Blvd location, so inventorySetQuantities silently no-ops (0
    userErrors, writes nothing). The script never called inventoryActivate, and
    counted "batch had no userErrors" as success (setOk += chunk.length - ue.length,
    also treating a null data payload as OK), so it logged "~2000 ok, 0 errors"
    while its own re-scan showed 0 landed — and still exited 0, so the hourly job
    logged success.
    
    Fix (mirrors the proven sweep-all-active.mjs activate-then-set pattern):
    - add inventoryActivate for every not-stocked item before the on_hand set
    - honest accounting: relabel the write count "submitted", treat null data as
      an error, never as OK
    - race-free targeted VERIFY: re-read the exact inventoryItem ids written (by id)
      instead of a fresh newest-N scan that can shift between scan and verify
    - exit non-zero when the verify shows anything still wrong, so the hourly job
      and canaries SEE the failure instead of logging false success
    
    Scan-time $0 guard is unchanged, so activation at QTY cannot re-inflate a
    $0/quote-only variant (TK-11357).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 shopify/scripts/inventory-set-2026-newest.mjs | 65 +++++++++++++++++++++++----
 1 file changed, 56 insertions(+), 9 deletions(-)

diff --git a/shopify/scripts/inventory-set-2026-newest.mjs b/shopify/scripts/inventory-set-2026-newest.mjs
index 8a21d20a..328b45b2 100644
--- a/shopify/scripts/inventory-set-2026-newest.mjs
+++ b/shopify/scripts/inventory-set-2026-newest.mjs
@@ -87,6 +87,13 @@ async function scan() {
 
 // ---- fix ----
 const INV_SET = `mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{field message code}}}`;
+// inventorySetQuantities can ONLY set on_hand where the inventory item already has an inventory
+// level at the location. For a tracked-but-not-stocked item it does NOT create that level — it
+// no-ops (returns 0 userErrors, writes nothing). That silent no-op was the false-success bug
+// (TK-11400): the newest priced products are tracked but never activated at Ventura Blvd, so
+// every set landed nowhere while the script reported "~N ok, 0 errors". inventoryActivate
+// creates the level (matches the proven sweep-all-active.mjs step 4).
+const INV_ACT = `mutation($id:ID!,$loc:ID!,$qty:Int!){inventoryActivate(inventoryItemId:$id,locationId:$loc,available:$qty){userErrors{message}}}`;
 
 async function fix(fixes) {
   const untracked = fixes.filter(f => !f.tracked);
@@ -101,17 +108,50 @@ async function fix(fixes) {
     process.stdout.write(`  track ${Math.min(i + 20, untracked.length)}/${untracked.length}\r`);
   }
   if (untracked.length) console.log(`\n  tracking enabled: ${trkOk} ok, ${trkErr.length} errors ${trkErr.length ? JSON.stringify(trkErr.slice(0, 3)) : ''}`);
-  // 2) inventorySetQuantities on_hand=QTY in chunks of 200
+  // 2) ACTIVATE any not-stocked item at the location (create the inventory level) so the
+  //    on_hand set below can actually persist. All flagged variants here are already price>0
+  //    (the scan guard skips $0/quote-only), so activating at QTY cannot re-inflate a $0 variant.
+  const notStocked = fixes.filter(f => !f.stocked);
+  let actOk = 0; const actErr = [];
+  for (let i = 0; i < notStocked.length; i++) {
+    const f = notStocked[i];
+    const r = await gql(INV_ACT, { id: f.iid, loc: LOC, qty: QTY });
+    if (!r.data?.inventoryActivate) actErr.push('no-data:' + JSON.stringify(r.errors || r).slice(0, 120));
+    else { const ue = r.data.inventoryActivate.userErrors || []; if (ue.length) actErr.push(...ue.map(e => e.message)); else actOk++; }
+    process.stdout.write(`  activate ${i + 1}/${notStocked.length}\r`);
+  }
+  if (notStocked.length) console.log(`\n  activated at loc: ${actOk} ok, ${actErr.length} errors ${actErr.length ? JSON.stringify(actErr.slice(0, 3)) : ''}`);
+  // 3) inventorySetQuantities on_hand=QTY in chunks of 200. NOTE: this "submitted" count is NOT
+  //    proof of success — a null data payload or a silent no-op both look error-free here. The
+  //    authoritative check is the targeted VERIFY re-read below.
   const pairs = fixes.map(f => ({ inventoryItemId: f.iid, locationId: LOC, quantity: QTY }));
-  let setOk = 0; const setErr = [];
+  let submitted = 0; const setErr = [];
   for (let i = 0; i < pairs.length; i += 200) {
     const chunk = pairs.slice(i, i + 200);
     const r = await gql(INV_SET, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: chunk } });
-    const ue = r.data?.inventorySetQuantities?.userErrors || [];
-    if (ue.length) setErr.push(...ue); setOk += chunk.length - ue.length;
+    if (!r.data?.inventorySetQuantities) { setErr.push('no-data:' + JSON.stringify(r.errors || r).slice(0, 120)); continue; } // top-level error = NOT ok
+    const ue = r.data.inventorySetQuantities.userErrors || [];
+    if (ue.length) setErr.push(...ue.map(e => (e.message || JSON.stringify(e)))); submitted += chunk.length - ue.length;
     process.stdout.write(`  set ${Math.min(i + 200, pairs.length)}/${pairs.length}\r`);
   }
-  console.log(`\n  inventory set on_hand=${QTY}: ~${setOk} ok, ${setErr.length} errors ${setErr.length ? JSON.stringify(setErr.slice(0, 3)) : ''}`);
+  console.log(`\n  inventory set on_hand=${QTY}: ${submitted}/${pairs.length} submitted, ${setErr.length} errors ${setErr.length ? JSON.stringify(setErr.slice(0, 3)) : ''}`);
+}
+
+// ---- targeted verify: re-read the EXACT items we wrote (by inventoryItem id) and confirm
+//      on_hand==QTY at LOC. Avoids the newest-N re-scan race (a new product created between
+//      scan and verify would shift the window and mis-measure). This is the source of truth. ----
+async function verifyItems(fixes) {
+  const ids = fixes.map(f => f.iid);
+  const Q = `query($ids:[ID!]!){nodes(ids:$ids){... on InventoryItem{id inventoryLevel(locationId:"${LOC}"){quantities(names:["on_hand"]){name quantity}}}}}`;
+  let ok = 0, bad = 0; const badSample = [];
+  for (let i = 0; i < ids.length; i += 100) {
+    const r = await gql(Q, { ids: ids.slice(i, i + 100) });
+    for (const n of (r.data?.nodes || [])) {
+      const onh = n?.inventoryLevel?.quantities?.find(x => x.name === 'on_hand')?.quantity;
+      if (onh === QTY) ok++; else { bad++; if (badSample.length < 3) badSample.push({ id: n?.id, onh }); }
+    }
+  }
+  return { ok, bad, badSample };
 }
 
 (async () => {
@@ -121,8 +161,15 @@ async function fix(fixes) {
   console.log(`variants: ${s.totV}  already 2026&ok: ${s.correct}  NEED FIX: ${s.fixes.length}`);
   if (!s.fixes.length) { console.log('nothing to do.'); return; }
   if (DRY) { console.log('--dry: no writes. sample:', JSON.stringify(s.fixes.slice(0, 5), null, 1)); return; }
-  await fix(s.fixes);
-  // verify
-  s = await scan();
-  console.log(`VERIFY -> variants: ${s.totV}  ok: ${s.correct}  remaining: ${s.fixes.length}`);
+  const wrote = s.fixes;
+  await fix(wrote);
+  // verify — re-read the exact items we wrote (race-free, authoritative)
+  const v = await verifyItems(wrote);
+  console.log(`VERIFY -> wrote: ${wrote.length}  now on_hand=${QTY}: ${v.ok}  still wrong: ${v.bad}${v.bad ? '  ' + JSON.stringify(v.badSample) : ''}`);
+  if (v.bad) {
+    console.error(`FAILED: ${v.bad}/${wrote.length} variants still not at on_hand=${QTY} after activate+set — the write did not persist.`);
+    process.exitCode = 1; // non-zero so the hourly job (and any canary) SEES the failure instead of logging success
+  } else {
+    console.log(`OK: all ${wrote.length} written variants confirmed on_hand=${QTY} at ${LOC}.`);
+  }
 })();

← cfff3faa TK-11422 + TK-11418: scope VCC's shared-catalog queries to t  ·  back to Designer Wallcoverings  ·  TK-11418: stamp shopify_product_id by primary key, not by am 020aa93c →