← 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
A scripts/price-sheets/add-roll-variant-edge.mjs
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 →