[object Object]

← back to Dw Yolo Loop

Add Schumacher add-roll-variant builder (532 targets, inventory=2026, scope-gated preflight, reversible) — paused for upgraded token

2cde42a5cd2f6d530117ade58c08acad59831e51 · 2026-06-15 10:46:46 -0700 · Steve Abrams

Files touched

Diff

commit 2cde42a5cd2f6d530117ade58c08acad59831e51
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 15 10:46:46 2026 -0700

    Add Schumacher add-roll-variant builder (532 targets, inventory=2026, scope-gated preflight, reversible) — paused for upgraded token
---
 scripts/price-sheets/add-roll-variant.mjs | 76 +++++++++++++++++++++++++++++++
 1 file changed, 76 insertions(+)

diff --git a/scripts/price-sheets/add-roll-variant.mjs b/scripts/price-sheets/add-roll-variant.mjs
new file mode 100644
index 0000000..528ea6e
--- /dev/null
+++ b/scripts/price-sheets/add-roll-variant.mjs
@@ -0,0 +1,76 @@
+#!/usr/bin/env node
+/**
+ * add-roll-variant.mjs — for the 532 dark single-variant Schumacher products whose only
+ * variant is Size:"Sample" @ $4.25, ADD a real Size:"Roll" variant priced at DW retail
+ * (cost/0.65/0.85) while KEEPING the $4.25 memo sample. (Steve's choice, 2026-06-15.)
+ *
+ * Standing rule: new variants get inventory=2026 on BOTH variants (sample + roll), set at
+ * the primary location. REQUIRES a token with read_locations + write_inventory in addition
+ * to write_products. If location read fails, the script REFUSES to run (won't half-build).
+ *
+ * Targets: /tmp/schu-addroll-targets.csv  =  productGid,sampleVarGid,sampleSku,rollSku,retail,cost
+ * Reversible: records created roll-variant ids to data/price-sheets/addroll-created.json
+ *             (revert = productVariantsBulkDelete those ids).
+ *
+ * USAGE:  node add-roll-variant.mjs --limit=2                 # dry-run, 2 products
+ *         node add-roll-variant.mjs --limit=2 --apply --i-am-steve   # LIVE test on 2
+ *         node add-roll-variant.mjs --apply --i-am-steve             # LIVE all 532
+ */
+import fs from 'node:fs';
+const SHOP='designer-laboratory-sandbox.myshopify.com', VER='2024-10';
+const TOKEN=(fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1]?.trim();
+if(!TOKEN){console.error('no token');process.exit(1);}
+const URL=`https://${SHOP}/admin/api/${VER}/graphql.json`;
+const args=Object.fromEntries(process.argv.slice(2).map(a=>{const[k,v]=a.replace(/^--/,'').split('=');return[k,v===undefined?true:v];}));
+const APPLY=args.apply===true&&args['i-am-steve']===true;
+const LIMIT=args.limit?parseInt(args.limit,10):Infinity;
+const INV_QTY=2026;
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+async function gql(q,v){for(let a=0;a<8;a++){let j;try{const r=await fetch(URL,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});j=await r.json();}catch(e){await sleep(1500*(a+1));continue;}if(j.errors){if(JSON.stringify(j.errors).includes('THROTTLED')){await sleep(2000*(a+1));continue;}return{__err:j.errors};}const t=j.extensions?.cost?.throttleStatus;if(t&&t.currentlyAvailable<400)await sleep(1200);return j.data;}throw new Error('retries');}
+
+let targets=fs.readFileSync('/tmp/schu-addroll-targets.csv','utf8').trim().split('\n').map(l=>{const[pid,svid,ssku,rsku,retail,cost]=l.split(',');return{pid,svid,ssku,rsku,retail:+retail,cost:+cost};});
+if(Number.isFinite(LIMIT))targets=targets.slice(0,LIMIT);
+console.log(`add-roll-variant — mode: ${APPLY?'⚠️  LIVE APPLY':'DRY-RUN'} | targets: ${targets.length} (of 532)`);
+
+// preflight: location read (proves the new token has the scopes). REFUSE if it fails.
+const loc=await gql(`{locations(first:5){nodes{id}}}`);
+if(loc?.__err){
+  console.error('\n⛔ token cannot read locations — needs read_locations + write_inventory.');
+  console.error('   '+JSON.stringify(loc.__err).slice(0,200));
+  console.error('   Route the upgraded token via the secrets skill, then re-run. Refusing to half-build.');
+  process.exit(2);
+}
+const LOCATION=loc.locations.nodes[0].id;
+console.log(`primary location: ${LOCATION}`);
+
+if(!APPLY){
+  console.log('\nDRY-RUN plan (first 6):');
+  for(const t of targets.slice(0,6))console.log(`  ${t.ssku}: keep Sample $4.25 + ADD Roll ${t.rsku} @ $${t.retail} (inv ${INV_QTY} both)`);
+  console.log(`\nWould add ${targets.length} roll variants. To execute: node add-roll-variant.mjs ${Number.isFinite(LIMIT)?`--limit=${LIMIT} `:''}--apply --i-am-steve`);
+  process.exit(0);
+}
+
+const C=`mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$productId,variants:$variants){productVariants{id sku inventoryItem{id}} userErrors{field message}}}`;
+const INV=`mutation($input:InventorySetOnHandQuantitiesInput!){inventorySetOnHandQuantities(input:$input){userErrors{field message}}}`;
+const QII=`query($id:ID!){node(id:$id){... on ProductVariant{inventoryItem{id}}}}`;
+
+const created=[]; let ok=0,err=0,invok=0,inverr=0;
+for(const t of targets){
+  // 1) add the Roll variant
+  const d=await gql(C,{productId:t.pid,variants:[{price:t.retail.toFixed(2),inventoryItem:{sku:t.rsku,tracked:true},optionValues:[{optionName:'Size',name:'Roll'}]}]});
+  if(d?.__err){err++;if(err<=10)console.log('  ERR',t.ssku,JSON.stringify(d.__err).slice(0,160));continue;}
+  const ue=d.productVariantsBulkCreate?.userErrors||[];
+  if(ue.length){err++;if(err<=10)console.log('  uerr',t.ssku,JSON.stringify(ue));continue;}
+  const rollVar=d.productVariantsBulkCreate.productVariants[0];
+  created.push({pid:t.pid,rollVid:rollVar.id,rollSku:rollVar.sku}); ok++;
+  // 2) inventory=2026 on BOTH variants
+  const sii=(await gql(QII,{id:t.svid}))?.node?.inventoryItem?.id;
+  const items=[rollVar.inventoryItem?.id, sii].filter(Boolean);
+  const setq=items.map(id=>({inventoryItemId:id,locationId:LOCATION,quantity:INV_QTY}));
+  const di=await gql(INV,{input:{reason:'correction',setQuantities:setq}});
+  if(di?.__err||di?.inventorySetOnHandQuantities?.userErrors?.length){inverr++;if(inverr<=10)console.log('  inv-err',t.rsku,JSON.stringify(di?.__err||di.inventorySetOnHandQuantities.userErrors).slice(0,140));}else invok++;
+  if((ok+err)%100===0)console.log(`  progress: ${ok} created / ${err} err / inv ${invok} ok ${inverr} err`);
+  fs.writeFileSync('data/price-sheets/addroll-created.json',JSON.stringify(created,null,2));
+}
+console.log(`\nDONE — ${ok} roll variants added, ${err} errors | inventory set: ${invok} ok, ${inverr} err`);
+console.log('reversible: data/price-sheets/addroll-created.json (productVariantsBulkDelete to undo)');

← e3ad9a1 Kravet dedup sweep: bulk full-catalog true-dup detector (ven  ·  back to Dw Yolo Loop  ·  Trending 2026 tag reconciler: newest-500 active + all Artmur 7f7c9f1 →