← back to Dw Yolo Loop

scripts/price-sheets/add-roll-variant-edge.mjs

95 lines

#!/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.
  //    Idempotent: a prior cap-blocked run may have already renamed (Size:Sample)
  //    but never created the Roll (created.json only records AFTER a create), so
  //    handle the already-renamed state instead of skipping it.
  const ov=await gql(QOV,{id:t.pid});
  const opt=ov?.node?.options?.[0];
  if(!opt){err++;console.log('  skip(no opt)',t.ssku);continue;}
  const vals=opt.optionValues||[];
  // already has the Roll variant? skip (idempotent re-run)
  if(vals.some(x=>x.name==='Roll')){console.log('  skip(roll exists)',t.rsku);continue;}
  const dtId=vals.find(x=>x.name==='Default Title')?.id;
  if(opt.name==='Size'&&vals.some(x=>x.name==='Sample')){
    // rename already done by a prior run — go straight to create
  }else if(dtId){
    const ru=await gql(OPTU,{productId:t.pid,option:{id:opt.id,name:'Size'},optionValuesToUpdate:[{id:dtId,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;}
  }else{err++;console.log('  skip(no opt val)',t.ssku,`opt=${opt.name}:[${vals.map(v=>v.name).join(',')}]`);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}`);