[object Object]

← back to Dw Yolo Loop

Add Schumacher edge add-roll handler (37 Default-Title via option-rename; 4 oddballs flagged for manual; separate revert log)

e96c9726fcb76d448de0dcae08fc4a7f38f8a550 · 2026-06-15 11:08:32 -0700 · Steve Abrams

Files touched

Diff

commit e96c9726fcb76d448de0dcae08fc4a7f38f8a550
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 15 11:08:32 2026 -0700

    Add Schumacher edge add-roll handler (37 Default-Title via option-rename; 4 oddballs flagged for manual; separate revert log)
---
 scripts/price-sheets/add-roll-variant-edge.mjs | 84 ++++++++++++++++++++++++++
 1 file changed, 84 insertions(+)

diff --git a/scripts/price-sheets/add-roll-variant-edge.mjs b/scripts/price-sheets/add-roll-variant-edge.mjs
new file mode 100644
index 0000000..0a85b71
--- /dev/null
+++ b/scripts/price-sheets/add-roll-variant-edge.mjs
@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+/**
+ * add-roll-variant-edge.mjs — the 37 edge Schumacher products whose dark single variant
+ * lives under the DEFAULT option (Title:["Default Title"]) instead of Size:["Sample"].
+ * Bring them in line with the clean 532: rename option Title->Size, value Default Title->
+ * Sample, then ADD a Size:"Roll" variant @ DW retail and set inventory=2026 on both.
+ * Keeps the $4.25 memo sample.
+ *
+ * ONLY processes rows where opt=Title / value=Default Title / sampleSku ends -Sample.
+ * The 4 true oddballs (roll-style sku already at $4.25, or opt=Type) are LEFT for manual
+ * review — printed at the end, never auto-touched.
+ *
+ * Separate revert log (data/price-sheets/addroll-edge-created.json) so it never collides
+ * with the main 532 run. Reversible: delete created roll variants + (optionally) rename back.
+ *
+ * USAGE: node add-roll-variant-edge.mjs --limit=1                  # dry-run
+ *        node add-roll-variant-edge.mjs --limit=1 --apply --i-am-steve
+ *        node add-roll-variant-edge.mjs --apply --i-am-steve       # all 37
+ */
+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');}
+
+const all=fs.readFileSync('/tmp/schu-addroll-edge.csv','utf8').trim().split('\n').map(l=>{const[pid,optId,optName,optVal,svid,ssku,rsku,retail,cost]=l.split('|');return{pid,optId,optName,optVal,svid,ssku,rsku,retail:+retail,cost:+cost};});
+const standard=all.filter(t=>t.optName==='Title'&&t.optVal==='Default Title'&&/-sample$/i.test(t.ssku));
+const oddballs=all.filter(t=>!(t.optName==='Title'&&t.optVal==='Default Title'&&/-sample$/i.test(t.ssku)));
+
+console.log(`edge — mode: ${APPLY?'⚠️  LIVE APPLY':'DRY-RUN'} | standard: ${standard.length} | oddballs (manual): ${oddballs.length}`);
+if(oddballs.length){console.log('\n⚠ ODDBALLS (left for manual review):');for(const o of oddballs)console.log(`   ${o.ssku||'(no sku)'}  opt=${o.optName}:[${o.optVal}]  retail $${o.retail}`);}
+
+let created=[]; try{created=JSON.parse(fs.readFileSync('data/price-sheets/addroll-edge-created.json','utf8'));}catch{}
+const donePids=new Set(created.map(c=>c.pid));
+let targets=standard.filter(t=>!donePids.has(t.pid));
+if(donePids.size)console.log(`(skipping ${standard.length-targets.length} already-created)`);
+if(Number.isFinite(LIMIT))targets=targets.slice(0,LIMIT);
+
+if(!APPLY){
+  console.log('\nDRY-RUN plan (first 5):');
+  for(const t of targets.slice(0,5))console.log(`  ${t.ssku}: rename Title->Size, "Default Title"->"Sample"; ADD Roll ${t.rsku} @ $${t.retail}; inv ${INV_QTY} both`);
+  console.log(`\nWould process ${targets.length} standard edges. To execute: node add-roll-variant-edge.mjs ${Number.isFinite(LIMIT)?`--limit=${LIMIT} `:''}--apply --i-am-steve`);
+  process.exit(0);
+}
+
+const QOV=`query($id:ID!){node(id:$id){... on Product{options{id name optionValues{id name}}}}}`;
+const OPTU=`mutation($productId:ID!,$option:OptionUpdateInput!,$optionValuesToUpdate:[OptionValueUpdateInput!]){productOptionUpdate(productId:$productId,option:$option,optionValuesToUpdate:$optionValuesToUpdate){userErrors{field message}}}`;
+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 loc=await gql(`{locations(first:5){nodes{id}}}`);
+if(loc?.__err){console.error('⛔ location read failed — needs read_locations/write_inventory');process.exit(2);}
+const LOCATION=loc.locations.nodes[0].id;
+
+let ok=0,err=0,invok=0,inverr=0;
+for(const t of targets){
+  // 1) rename option Title->Size and its value Default Title->Sample
+  const ov=await gql(QOV,{id:t.pid});
+  const opt=ov?.node?.options?.[0];
+  const valId=opt?.optionValues?.find(x=>x.name==='Default Title')?.id;
+  if(!opt||!valId){err++;console.log('  skip(no opt val)',t.ssku);continue;}
+  const ru=await gql(OPTU,{productId:t.pid,option:{id:opt.id,name:'Size'},optionValuesToUpdate:[{id:valId,name:'Sample'}]});
+  const rue=ru?.__err||ru?.productOptionUpdate?.userErrors;
+  if(ru?.__err||(rue&&rue.length)){err++;if(err<=10)console.log('  rename-err',t.ssku,JSON.stringify(rue).slice(0,140));continue;}
+  // 2) add Size:Roll @ retail
+  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'}]}]});
+  const ue=d?.__err||d?.productVariantsBulkCreate?.userErrors;
+  if(d?.__err||(ue&&ue.length)){err++;if(err<=10)console.log('  create-err',t.ssku,JSON.stringify(ue).slice(0,140));continue;}
+  const rollVar=d.productVariantsBulkCreate.productVariants[0];
+  created.push({pid:t.pid,rollVid:rollVar.id,rollSku:rollVar.sku,renamedOpt:opt.id}); ok++;
+  // 3) inventory 2026 both
+  const sii=(await gql(QII,{id:t.svid}))?.node?.inventoryItem?.id;
+  const items=[rollVar.inventoryItem?.id,sii].filter(Boolean);
+  const di=await gql(INV,{input:{reason:'correction',setQuantities:items.map(id=>({inventoryItemId:id,locationId:LOCATION,quantity:INV_QTY}))}});
+  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,120));}else invok++;
+  fs.writeFileSync('data/price-sheets/addroll-edge-created.json',JSON.stringify(created,null,2));
+}
+console.log(`\nDONE — ${ok} edges processed, ${err} errors | inventory: ${invok} ok, ${inverr} err | oddballs left: ${oddballs.length}`);

← 11048a7 Schumacher add-roll: idempotent skip + accumulate revert-log  ·  back to Dw Yolo Loop  ·  Add resume wrapper + persist target CSVs for tomorrow's rema 50b1d33 →