[object Object]

← back to Designer Wallcoverings

All-day uploader: cadence-import --max + --activate (readiness-gated ACTIVE + inventory 2026) + defensive denylist; hourly runner (budget-aware, kill-switch, resumable) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

87a9e7d6e2ac11f6ffe4ffeec390f18bf45a0bfc · 2026-06-17 14:40:18 -0700 · SteveStudio2

Files touched

Diff

commit 87a9e7d6e2ac11f6ffe4ffeec390f18bf45a0bfc
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed Jun 17 14:40:18 2026 -0700

    All-day uploader: cadence-import --max + --activate (readiness-gated ACTIVE + inventory 2026) + defensive denylist; hourly runner (budget-aware, kill-switch, resumable)
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 shopify/scripts/cadence/cadence-import.js     | 92 +++++++++++++++++++++++----
 shopify/scripts/cadence/run-cadence-hourly.sh | 37 +++++++++++
 2 files changed, 116 insertions(+), 13 deletions(-)

diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
index 92de05c0..4368a6af 100644
--- a/shopify/scripts/cadence/cadence-import.js
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -40,7 +40,15 @@ const SLOT = val('--slot', 'am');
 const ONLY = val('--only', null);   // restrict to a single vendor (e.g. --only Thibaut)
 const VENDORS_PER_SLOT = parseInt(val('--vendors', '10'), 10);
 const SKUS_PER_VENDOR = parseInt(val('--skus', '20'), 10);
+const MAX_PRODUCTS = parseInt(val('--max', '0'), 10);   // 0 = unlimited; total products this run (budget-bound by the hourly runner)
+const ACTIVATE = flag('--activate');                    // auto-activate readiness-passing products to ACTIVE + inventory=2026 (Steve 2026-06-17)
 const TODAY = new Date().toISOString().slice(0, 10);
+// Defensive vendor denylist — even if a vendor is in vendors.js + discount_confirmed, NEVER
+// import these (belt-and-suspenders for sub-brand/registry drift). Today vendors.js already
+// excludes them by omission; this hard-blocks a future accidental add. (compliance officer, 2026-06-17)
+const DENY_VENDORS = new Set(['Nicolette Mayer','Phillip Jeffries','Et Cie','Spoonflower','Colefax & Fowler','Carlisle','Paul Montgomery','Cowtan & Tout','Rebel Walls','Koroseal','Newmor','Wolf Gordon','Innovations','Scalamandre','Desima',
+  'Romo']);   // Romo: vendors.js note "confirm tariff treatment w/ Steve before bulk import" → hold from the auto-uploader until confirmed
+const INV_LOCATION = 'gid://shopify/Location/5795643504';  // 15442 Ventura Blvd. (inventory-set-2026.js)
 const RETAIL = c => Math.round((c / 0.65 / 0.85) * 100) / 100;
 const SAMPLE_PRICE = '4.25';   // correct price for the Sample variant (Steve 2026-06-11)
 
@@ -150,6 +158,7 @@ function gate() {
   const confirmed = confirmedDiscountVendors();
   const rows = [];
   for (const [vendor, cfg] of Object.entries(VENDORS)) {
+    if (DENY_VENDORS.has(vendor)) { rows.push({ vendor, cfg, ready: false, why: 'DENYLISTED', netnew: 0 }); continue; }
     if (!confirmed.has(vendor)) { rows.push({ vendor, cfg, ready: false, why: 'discount-not-confirmed', netnew: 0 }); continue; }
     let netnew = 0, why = 'READY';
     try {
@@ -187,7 +196,7 @@ function selectSkus(cfg, limit) {
 }
 
 // ---- build the Shopify productSet input (generic across standard-schema vendors) ----
-function buildInput(vendor, cfg, row, retail) {
+function buildInput(vendor, cfg, row, retail, activate) {
   const stripCodes = s => String(s||'').replace(/\(\s*[A-Za-z]?\d{3,}[^)]*\)/g, '').replace(/\s{2,}/g,' ').trim(); // kill (T457) etc
   const pattern = titleCase(stripCodes(row.pattern_name || ''));
   const rawColor = cleanColor(row.color_name, row.pattern_name, row.mfr_sku);
@@ -215,8 +224,15 @@ function buildInput(vendor, cfg, row, retail) {
   m('custom','cost',row.cost.toFixed(2),ND);                 // REAL cost
   m('custom','price_updated_at',TODAY,'date');                // 30-day refresh audit
   m('global','unit_of_measure',`Priced Per ${cfg.soldBy}`,SL);
+  // Auto-activate-to-live readiness gate (Steve 2026-06-17 "auto-activate to live"): a product
+  // goes ACTIVE only if it's complete — real image + width + pattern (cost is already gated by
+  // costExpr>0 upstream; title is banned-word-clean by construction). Anything incomplete stays
+  // DRAFT (still "uploaded", fixable later) — never a broken product on the storefront.
+  const ready = !!(row.image_url && String(row.width || '').trim() && pattern);
+  const willActivate = !!(activate && ready);
   const input = {
-    title, handle, vendor, productType: cfg.productType, status: 'DRAFT',  // never ACTIVE on create (Steve rule)
+    title, handle, vendor, productType: cfg.productType,
+    status: willActivate ? 'ACTIVE' : 'DRAFT',  // DRAFT unless --activate AND readiness passes
     tags: [vendor, cfg.productType, `Priced Per ${cfg.soldBy}`, ...(row.collection?[`Collection: ${row.collection}`]:[]), ...(color?[color]:[])],
     descriptionHtml: `<p>${esc(intro)}</p><ul>${specs.join('')}</ul>`,
     metafields: mf,
@@ -227,11 +243,46 @@ function buildInput(vendor, cfg, row, retail) {
     ],
   };
   if (row.image_url) input.files = [{ originalSource: webImage(row.image_url), contentType:'IMAGE', alt: core }];
-  return { input, title, retail };
+  return { input, title, retail, willActivate, ready };
 }
 
 const PRODUCTSET = `mutation push($input: ProductSetInput!){productSet(synchronous:true,input:$input){product{id handle status} userErrors{field message}}}`;
 
+// ---- restore map (one batch_id per slot → a bad hour is selectable + revertible) ----
+const BATCH_ID = `upload-${SLOT}-${new Date().toISOString().replace(/[:.]/g,'-')}`;
+const RESTORE_FILE = path.join(DATADIR, `upload-restore-${TODAY}.jsonl`);
+function appendRestore(rec) {
+  try { fs.mkdirSync(DATADIR,{recursive:true}); fs.appendFileSync(RESTORE_FILE, JSON.stringify({ ...rec, batch_id: BATCH_ID, ts: new Date().toISOString() }) + '\n'); }
+  catch (e) { /* never let audit logging break a create */ }
+}
+
+// ---- inventory=2026 on BOTH variants at activation (hard rule new-products-inventory-2026) ----
+// Reuses inventory-set-2026.js's exact mechanism: look up inventoryItem ids by SKU, then
+// inventorySetQuantities on_hand=2026 at the Ventura Blvd location. Inventory sets do NOT
+// consume the variant-creation cap (they touch existing variants), so this is cap-free.
+const INV_LOOKUP = `query($q:String!){productVariants(first:250,query:$q){edges{node{sku inventoryItem{id}}}}}`;
+const INV_SET = `mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{field message code}}}`;
+async function setInventory2026(skus) {
+  if (!skus.length) return { set: 0, errors: [] };
+  const map = new Map();
+  for (let i=0;i<skus.length;i+=40) {
+    const batch = skus.slice(i, i+40);
+    const q = batch.map(s => `sku:"${String(s).replace(/"/g,'\\"')}"`).join(' OR ');
+    const r = await gqlRetry(INV_LOOKUP, { q });
+    for (const e of (r.json?.data?.productVariants?.edges || [])) {
+      if (e.node.sku && e.node.inventoryItem?.id) map.set(e.node.sku, e.node.inventoryItem.id);
+    }
+  }
+  const pairs = skus.filter(s => map.has(s)).map(s => ({ inventoryItemId: map.get(s), locationId: INV_LOCATION, quantity: 2026 }));
+  const errors = [];
+  for (let i=0;i<pairs.length;i+=250) {
+    const r = await gqlRetry(INV_SET, { input: { name:'on_hand', reason:'correction', ignoreCompareQuantity:true, quantities: pairs.slice(i,i+250) } });
+    const ue = r.json?.data?.inventorySetQuantities?.userErrors || [];
+    if (ue.length) errors.push(...ue);
+  }
+  return { set: pairs.length, mapped: map.size, errors };
+}
+
 // ---- PRE-PUSH DEDUP GUARD (Steve 2026-06-12, DTD-picked structural fix) ----
 // The net-new gate selects rows where shopify_product_id='' and blindly CREATEs. If that link
 // is empty due to drift (a Mac2-created product whose id never reached this DB, a late sync, a
@@ -264,6 +315,7 @@ async function createProduct(table, row, payload) {
     // log our computed price to the DB (the hard rule) but DO NOT overwrite the live Shopify product.
     logPriceToDb(table, row.dw_sku, payload.retail);
     psql(`UPDATE ${table} SET shopify_product_id='${num}' WHERE dw_sku='${row.dw_sku.replace(/'/g,"''")}' AND coalesce(shopify_product_id::text,'')='';`);
+    appendRestore({ table, mfr_sku: row.mfr_sku, dw_sku: row.dw_sku, shopify_product_id: num, action: 'linked-existing', activated: false });
     return { ok: true, pid: existing.id, action: 'linked-existing', status: existing.status };
   }
   // dw_unified FIRST
@@ -279,8 +331,11 @@ async function createProduct(table, row, payload) {
   const pid = r.json?.data?.productSet?.product?.id;
   // CRITICAL: only count as created when a real product id comes back (else net-new → retried).
   if (!pid) return { ok:false, err: (topErr || r.raw || 'no-pid (empty response)').slice(0,160) };
-  psql(`UPDATE ${table} SET shopify_product_id='${String(pid).replace(/.*\//,'')}' WHERE dw_sku='${row.dw_sku.replace(/'/g,"''")}';`);
-  return { ok:true, pid };
+  const num = String(pid).replace(/.*\//,'');
+  psql(`UPDATE ${table} SET shopify_product_id='${num}' WHERE dw_sku='${row.dw_sku.replace(/'/g,"''")}';`);
+  const activated = !!payload.willActivate;
+  appendRestore({ table, mfr_sku: row.mfr_sku, dw_sku: row.dw_sku, shopify_product_id: num, action: 'created', activated, status: activated ? 'ACTIVE' : 'DRAFT' });
+  return { ok:true, pid, activated, skus: activated ? [row.dw_sku, `${row.dw_sku}-Sample`] : [] };
 }
 
 // ---- main ----
@@ -302,30 +357,40 @@ async function createProduct(table, row, payload) {
   for (let i=0;i<Math.min(VENDORS_PER_SLOT, ready.length);i++) picked.push(ready[(start+i)%ready.length]);
   console.log(`\n=== ${COMMIT?'LIVE COMMIT':'DRY-RUN'} — slot ${SLOT}: ${picked.length} vendor(s) × up to ${SKUS_PER_VENDOR} SKUs ===`);
 
-  const plan = []; let created=0, failed=0, linked=0;
+  const plan = []; let created=0, failed=0, linked=0; const activatedSkus=[]; let hitMax=false, hitCap=false;
+  if (MAX_PRODUCTS) console.log(`(--max ${MAX_PRODUCTS} products this run)${ACTIVATE?'  --activate: readiness-passing → ACTIVE + inventory 2026':''}`);
   for (const r of picked) {
+    if (hitMax || hitCap) break;
     if (COMMIT) ensureOurPriceCol(r.cfg.table);
     const skus = selectSkus(r.cfg, SKUS_PER_VENDOR);
     console.log(`\n${r.vendor} — ${skus.length} SKU(s) (cost ${r.cfg.costExpr}):`);
     for (const row of skus) {
       // MAP-priced vendors (sellExpr) sell at MAP; everyone else at cost/0.65/0.85.
       const retail = (r.cfg.sellExpr && row.sell > 0) ? Math.round(row.sell * 100) / 100 : RETAIL(row.cost);
-      const payload = buildInput(r.vendor, r.cfg, row, retail);
-      plan.push({ vendor:r.vendor, dw_sku:row.dw_sku, cost:row.cost, retail, sample:SAMPLE_PRICE, title:payload.title });
-      if (!COMMIT) { console.log(`  · ${row.dw_sku}  cost $${row.cost.toFixed(2)} → roll $${retail}  sample $${SAMPLE_PRICE}  | ${payload.title.slice(0,52)}`); continue; }
+      const payload = buildInput(r.vendor, r.cfg, row, retail, ACTIVATE);
+      plan.push({ vendor:r.vendor, dw_sku:row.dw_sku, cost:row.cost, retail, sample:SAMPLE_PRICE, title:payload.title, willActivate:payload.willActivate });
+      if (!COMMIT) { console.log(`  · ${row.dw_sku}  cost $${row.cost.toFixed(2)} → roll $${retail}  sample $${SAMPLE_PRICE}  ${ACTIVATE?(payload.willActivate?'[→ACTIVE]':'[draft:not-ready]'):''}  | ${payload.title.slice(0,48)}`); continue; }
       const res = await createProduct(r.cfg.table, row, payload);
       if (res.ok && res.action === 'linked-existing') { linked++; console.log(`  ↪ ${row.dw_sku} already on Shopify (${res.status}) → backfilled link ${String(res.pid).replace(/.*\//,'')}, skipped create (dedup guard)`); }
-      else if (res.ok) { created++; if(created%10===0) process.stdout.write(`\r  created ${created}…`); }
+      else if (res.ok) { created++; if (res.activated && res.skus) activatedSkus.push(...res.skus); if(created%10===0) process.stdout.write(`\r  created ${created}…`); }
       else { failed++; console.log(`  ✗ ${row.dw_sku} ${JSON.stringify(res.err).slice(0,140)}`); }
-      if (res.stop) { console.log(`\n⛔ DAILY_VARIANT_LIMIT reached — stopping (resets in ~24h). created ${created} this run.`); break; }
+      if (res.stop) { console.log(`\n⛔ DAILY_VARIANT_LIMIT reached — stopping (resets in ~24h). created ${created} this run.`); hitCap=true; break; }
+      if (MAX_PRODUCTS && (created + linked) >= MAX_PRODUCTS) { console.log(`\n· --max ${MAX_PRODUCTS} reached — stopping this slot (resumes next slot via cursor).`); hitMax=true; break; }
     }
-    if (failed && /* bail across vendors too once the daily cap is hit */ false) break;
+  }
+  // auto-activate-to-live: set inventory=2026 on BOTH variants of every product that went ACTIVE.
+  if (COMMIT && ACTIVATE && activatedSkus.length) {
+    console.log(`\nsetting inventory=2026 on ${activatedSkus.length} variant(s) (${activatedSkus.length/2} activated products) at Ventura Blvd…`);
+    try { const inv = await setInventory2026(activatedSkus); console.log(`  inventory set: ${inv.set} pairs, mapped ${inv.mapped}, errors ${inv.errors.length}`); if (inv.errors.length) console.log('  inv errors:', JSON.stringify(inv.errors).slice(0,200)); }
+    catch (e) { console.warn('  inventory-set failed (products are ACTIVE but inventory not 2026 — re-run inventory-set-2026.js):', e.message); }
   }
   // advance rotation
   saveCursor({ idx: (start + picked.length) % ready.length, lastSlot: SLOT, lastRun: TODAY });
   const planFile = path.join(DATADIR, `cadence-plan-${SLOT}-${TODAY}.json`);
   fs.mkdirSync(DATADIR,{recursive:true}); fs.writeFileSync(planFile, JSON.stringify(plan,null,1));
-  console.log(`\n\n${COMMIT?`CREATED ${created}, linked-existing ${linked} (dedup guard), failed ${failed}`:`DRY-RUN planned ${plan.length} products`} → ${planFile}`);
+  const activatedN = activatedSkus.length / 2;
+  console.log(`\n\n${COMMIT?`CREATED ${created} (${ACTIVATE?activatedN+' ACTIVE / '+(created-activatedN)+' DRAFT':'all DRAFT'}), linked-existing ${linked} (dedup guard), failed ${failed}`:`DRY-RUN planned ${plan.length} products${ACTIVATE?` (${plan.filter(p=>p.willActivate).length} would activate)`:''}`} → ${planFile}`);
+  if (COMMIT) console.log(`restore map: ${RESTORE_FILE} (batch ${BATCH_ID})`);
   if (!COMMIT) console.log('Re-run with --commit to create these on Shopify (DRAFT status, dw_unified logged first).');
 
   // refresh the CNCP "Price Coverage" goal panel now that new cost may have landed.
@@ -334,4 +399,5 @@ async function createProduct(table, row, payload) {
     execFileSync('node', [path.join(__dirname, 'compute-price-coverage.js'), '--quiet'], { stdio: 'ignore' });
     console.log('price-coverage recomputed.');
   } catch (e) { console.warn('price-coverage recompute skipped:', e.message); }
+  if (hitCap) process.exit(3);   // resumable signal: daily variant cap hit, runner resumes next slot
 })();
diff --git a/shopify/scripts/cadence/run-cadence-hourly.sh b/shopify/scripts/cadence/run-cadence-hourly.sh
new file mode 100755
index 00000000..9e457ffb
--- /dev/null
+++ b/shopify/scripts/cadence/run-cadence-hourly.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# DW all-day new-product uploader — fires hourly 6am–5pm PT. Drains the already-staged vendor
+# backlog (cadence-import.js), AUTO-ACTIVATING readiness-passing products to live + inventory=2026
+# (Steve 2026-06-17). Cooperates with the shared ~1,000/day variant cap via budget.cjs ('upload'
+# category, leftover-aware) and the importer's own VARIANT_THROTTLE_EXCEEDED abort (exit 3).
+# Resumable: the firm rotation cursor (data/cadence-cursor.json) advances each slot so all
+# eligible firms get coverage across the day; the staged backlog drains continuously.
+set -u
+SHOP="$HOME/Projects/Designer-Wallcoverings/shopify"
+BUDGET="$HOME/Projects/designerwallcoverings/scripts/variant-budget/budget.cjs"
+KILL="$HOME/.dw-fixer-stop"
+LOG="/tmp/dw-cadence-hourly.log"
+exec >>"$LOG" 2>&1
+TS="$(date '+%F %T')"
+echo "═══════ cadence-hourly $TS ═══════"
+
+# global kill-switch (shared with roll-scale/other builders)
+[ -f "$KILL" ] && { echo "kill-switch $KILL present — skip slot"; exit 0; }
+
+# slot: am (hours 6-11) / pm (12-17), local time
+H=$(date +%H); H=${H#0}; SLOT=am; [ "${H:-0}" -ge 12 ] && SLOT=pm
+
+# ask the shared variant-budget ledger for this slot's grant. We aim for ~40 variants
+# (~20 products) per slot; budget.take returns min(request, upload-cap-remaining, global-remaining)
+# so on heavy days it hands back less (leftover-aware) and on light days up to the upload slice.
+REQ="${UPLOAD_SLOT_VARIANTS:-40}"
+GRANT=$(node "$BUDGET" take upload "$REQ" 2>/dev/null || echo "$REQ")
+MAXP=$(( GRANT / 2 ))   # 2 variants per product (Single Roll + Sample)
+echo "slot=$SLOT  budget grant=$GRANT variants → max $MAXP products"
+if [ "$MAXP" -lt 1 ]; then echo "no upload budget this slot — exit clean"; exit 0; fi
+
+cd "$SHOP" || { echo "FATAL: no $SHOP"; exit 1; }
+export $(grep -E "^SHOPIFY_ADMIN_TOKEN=" "$HOME/Projects/secrets-manager/.env" | xargs) 2>/dev/null
+node scripts/cadence/cadence-import.js --slot "$SLOT" --vendors 8 --skus 10 --max "$MAXP" --activate --commit
+RC=$?
+echo "cadence-import exit $RC  (3 = daily variant cap hit, resumes next slot)"
+echo "═══════ done $TS ═══════"

← 5964ee51 Add Artmura real logo (artmura.it) for brands page  ·  back to Designer Wallcoverings  ·  Wire Innovations into cadence vendors.js (cost=price_trade=l edced97e →