[object Object]

← back to Dw Yolo Loop

Resolve 4 Schumacher oddballs via sku-swap (bare->Roll, existing->-Sample); wire into tomorrow's resume wrapper

e36e0e2deb15605842f4d71836bfce6a9a4d8f51 · 2026-06-15 11:28:18 -0700 · Steve Abrams

Files touched

Diff

commit e36e0e2deb15605842f4d71836bfce6a9a4d8f51
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 15 11:28:18 2026 -0700

    Resolve 4 Schumacher oddballs via sku-swap (bare->Roll, existing->-Sample); wire into tomorrow's resume wrapper
---
 data/price-sheets/schu-addroll-oddball.csv        |  4 ++
 scripts/price-sheets/add-roll-variant-oddball.mjs | 79 +++++++++++++++++++++++
 scripts/price-sheets/resume-roll-adds.sh          |  6 +-
 3 files changed, 88 insertions(+), 1 deletion(-)

diff --git a/data/price-sheets/schu-addroll-oddball.csv b/data/price-sheets/schu-addroll-oddball.csv
new file mode 100644
index 0000000..065d1c8
--- /dev/null
+++ b/data/price-sheets/schu-addroll-oddball.csv
@@ -0,0 +1,4 @@
+gid://shopify/Product/7774638014515|gid://shopify/ProductOption/9913664962611|Title|Default Title|gid://shopify/ProductVariant/44043104780339|DWLK-824290|258.82|143
+gid://shopify/Product/7774642077747|gid://shopify/ProductOption/9913669058611|Title|Default Title|gid://shopify/ProductVariant/44043109302323|DWLK-824210|219|121
+gid://shopify/Product/7774849433651|gid://shopify/ProductOption/9913913344051|Title|Default Title|gid://shopify/ProductVariant/44044410028083|DWLK-824650|179.19|99
+gid://shopify/Product/7774907007027|gid://shopify/ProductOption/9913983959091|Type|Sample|gid://shopify/ProductVariant/44044692062259|DWLK-824640|179.19|99
\ No newline at end of file
diff --git a/scripts/price-sheets/add-roll-variant-oddball.mjs b/scripts/price-sheets/add-roll-variant-oddball.mjs
new file mode 100644
index 0000000..f0bc79a
--- /dev/null
+++ b/scripts/price-sheets/add-roll-variant-oddball.mjs
@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+/**
+ * add-roll-variant-oddball.mjs — the 4 Schumacher oddballs whose $4.25 variant sits on the
+ * BARE roll-sku (e.g. DWLK-824290, no -Sample suffix). Steve's choice (2026-06-15):
+ * SWAP to the catalog convention (roll = bare sku, sample = -Sample):
+ *   1) rename the existing variant's sku  DWLK-824290 -> DWLK-824290-Sample  (keeps $4.25)
+ *   2) rename its option -> Size, value -> "Sample"
+ *   3) ADD the bare sku DWLK-824290 as the Roll @ DW retail
+ *   4) inventory=2026 on BOTH variants
+ * Step 1 frees the bare sku BEFORE step 3 (avoids a duplicate-sku conflict).
+ *
+ * Targets: data/price-sheets/schu-addroll-oddball.csv  pid|optId|optName|optVal|svid|bareSku|retail|cost
+ * Revert log: data/price-sheets/addroll-oddball-created.json
+ *
+ * USAGE: node add-roll-variant-oddball.mjs --limit=1                 # dry-run
+ *        node add-roll-variant-oddball.mjs --apply --i-am-steve      # all 4 (needs daily variant cap available)
+ */
+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 all=fs.readFileSync('data/price-sheets/schu-addroll-oddball.csv','utf8').trim().split('\n').map(l=>{const[pid,optId,optName,optVal,svid,bare,retail,cost]=l.split('|');return{pid,optId,optName,optVal,svid,bare,retail:+retail,cost:+cost};});
+let created=[]; try{created=JSON.parse(fs.readFileSync('data/price-sheets/addroll-oddball-created.json','utf8'));}catch{}
+const donePids=new Set(created.map(c=>c.pid));
+let targets=all.filter(t=>!donePids.has(t.pid));
+if(donePids.size)console.log(`(skipping ${all.length-targets.length} already-done)`);
+if(Number.isFinite(LIMIT))targets=targets.slice(0,LIMIT);
+console.log(`oddball — mode: ${APPLY?'⚠️  LIVE APPLY':'DRY-RUN'} | targets: ${targets.length}`);
+
+if(!APPLY){
+  for(const t of targets)console.log(`  ${t.bare}: sku->${t.bare}-Sample (keep $4.25) | opt ${t.optName}->Size, "${t.optVal}"->"Sample" | ADD Roll ${t.bare} @ $${t.retail} | inv ${INV_QTY} both`);
+  console.log(`\nDRY-RUN. To execute: node add-roll-variant-oddball.mjs --apply --i-am-steve`);
+  process.exit(0);
+}
+
+const VU=`mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$productId,variants:$variants){userErrors{field message}}}`;
+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');process.exit(2);}
+const LOCATION=loc.locations.nodes[0].id;
+
+let ok=0,err=0;
+for(const t of targets){
+  // 1) rename existing variant sku -> bare-Sample (frees the bare sku)
+  const u=await gql(VU,{productId:t.pid,variants:[{id:t.svid,inventoryItem:{sku:`${t.bare}-Sample`}}]});
+  const uerr=u?.__err||u?.productVariantsBulkUpdate?.userErrors;
+  if(u?.__err||(uerr&&uerr.length)){err++;console.log('  sku-rename-err',t.bare,JSON.stringify(uerr).slice(0,140));continue;}
+  // 2) rename option -> Size, value -> Sample
+  const ov=await gql(QOV,{id:t.pid}); const opt=ov?.node?.options?.[0];
+  const valId=opt?.optionValues?.find(x=>x.name===t.optVal)?.id || opt?.optionValues?.[0]?.id;
+  const ru=await gql(OPTU,{productId:t.pid,option:{id:opt.id,name:'Size'},optionValuesToUpdate:[{id:valId,name:'Sample'}]});
+  const rerr=ru?.__err||ru?.productOptionUpdate?.userErrors;
+  if(ru?.__err||(rerr&&rerr.length)){err++;console.log('  opt-rename-err',t.bare,JSON.stringify(rerr).slice(0,140));continue;}
+  // 3) add Roll @ retail on the now-free bare sku
+  const d=await gql(C,{productId:t.pid,variants:[{price:t.retail.toFixed(2),inventoryItem:{sku:t.bare,tracked:true},optionValues:[{optionName:'Size',name:'Roll'}]}]});
+  const ce=d?.__err||d?.productVariantsBulkCreate?.userErrors;
+  if(d?.__err||(ce&&ce.length)){err++;console.log('  create-err',t.bare,JSON.stringify(ce).slice(0,160));continue;}
+  const rollVar=d.productVariantsBulkCreate.productVariants[0];
+  created.push({pid:t.pid,rollVid:rollVar.id,rollSku:rollVar.sku}); ok++;
+  // 4) inventory 2026 both
+  const sii=(await gql(QII,{id:t.svid}))?.node?.inventoryItem?.id;
+  const items=[rollVar.inventoryItem?.id,sii].filter(Boolean);
+  await gql(INV,{input:{reason:'correction',setQuantities:items.map(id=>({inventoryItemId:id,locationId:LOCATION,quantity:INV_QTY}))}});
+  fs.writeFileSync('data/price-sheets/addroll-oddball-created.json',JSON.stringify(created,null,2));
+  console.log(`  ✓ ${t.bare}: Sample ${t.bare}-Sample $4.25 + Roll ${t.bare} $${t.retail}`);
+}
+console.log(`\nDONE — ${ok} oddballs resolved, ${err} errors.`);
diff --git a/scripts/price-sheets/resume-roll-adds.sh b/scripts/price-sheets/resume-roll-adds.sh
index 8a3d0c4..bd76cb8 100755
--- a/scripts/price-sheets/resume-roll-adds.sh
+++ b/scripts/price-sheets/resume-roll-adds.sh
@@ -17,6 +17,8 @@ echo "--- clean 532 set ---"
 node scripts/price-sheets/add-roll-variant.mjs --apply --i-am-steve || true
 echo "--- edge 37 (Default-Title) ---"
 node scripts/price-sheets/add-roll-variant-edge.mjs --apply --i-am-steve || true
+echo "--- 4 oddballs (sku-swap) ---"
+node scripts/price-sheets/add-roll-variant-oddball.mjs --apply --i-am-steve || true
 
 # how many remain across both sets?
 REMAIN=$(node -e '
@@ -25,7 +27,9 @@ const done=new Set((()=>{try{return JSON.parse(fs.readFileSync("data/price-sheet
 const edone=new Set((()=>{try{return JSON.parse(fs.readFileSync("data/price-sheets/addroll-edge-created.json","utf8")).map(c=>c.pid)}catch{return[]}})());
 const clean=fs.readFileSync("/tmp/schu-addroll-targets.csv","utf8").trim().split("\n").filter(l=>!done.has(l.split(",")[0])).length;
 const edge=fs.readFileSync("/tmp/schu-addroll-edge.csv","utf8").trim().split("\n").filter(l=>{const[pid,,optName,optVal,,ssku]=l.split("|");return optName==="Title"&&optVal==="Default Title"&&/-sample$/i.test(ssku)&&!edone.has(pid)}).length;
-console.log(clean+edge);
+const odone=new Set((()=>{try{return JSON.parse(fs.readFileSync("data/price-sheets/addroll-oddball-created.json","utf8")).map(c=>c.pid)}catch{return[]}})());
+const odd=fs.readFileSync("data/price-sheets/schu-addroll-oddball.csv","utf8").trim().split("\n").filter(l=>!odone.has(l.split("|")[0])).length;
+console.log(clean+edge+odd);
 ')
 echo "remaining clean+edge roll-adds: $REMAIN"
 

← 50b1d33 Add resume wrapper + persist target CSVs for tomorrow's rema  ·  back to Dw Yolo Loop  ·  Fleet dedup: archived 559 true duplicate products live + 301 7532fe4 →