← back to Dw Yolo Loop

scripts/price-sheets/push-schu-roll-prices.mjs

71 lines

#!/usr/bin/env node
/**
 * push-schu-roll-prices.mjs — set the ROLL variant price = DW retail (cost/0.65/0.85)
 * for the newly-costed Schumacher SINGLE-ROLL wallcoverings. These were dark ($0 or the
 * $4.25 memo-sample trap); this gives them a real price for the first time.
 *
 * SAFETY (same rails as the Kravet MAP push):
 *  - Only the ROLL (non -sample) variant. The $4.25 memo SAMPLE is never touched.
 *  - cost is SINGLE-ROLL unit (verified) -> retail is per-roll. (YARD/PANEL/RL held out.)
 *  - Only WRITES where current roll price is garbage (<= $10, i.e. $0 / $4.25). A product
 *    that already has a real price (> $10) is NEVER clobbered -> flagged as anomaly + skipped.
 *  - Anomaly guard: retail/cur > 2.5x or < 0.4x on a real price -> skip.
 *  - Reversible: writes a plan json of {pid,vid,from,to} so a revert can restore.
 *
 * USAGE: node push-schu-roll-prices.mjs               # dry-run
 *        node push-schu-roll-prices.mjs --apply --i-am-steve
 */
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 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;}throw new Error(JSON.stringify(j.errors));}const t=j.extensions?.cost?.throttleStatus;if(t&&t.currentlyAvailable<400)await sleep(1200);return j.data;}throw new Error('retries');}
const isSample=v=>/(sample|memo|swatch)/i.test([v.title,v.sku].join(' '))||/-sample$/i.test(v.sku||'');

const rows=fs.readFileSync('/tmp/schu-roll-pricing.csv','utf8').trim().split('\n').map(l=>{const[gid,mfr,cost,retail]=l.split(',');return{gid,mfr,cost:+cost,retail:+retail};});
console.log(`push-schu-roll-prices — mode: ${APPLY?'⚠️  LIVE APPLY':'DRY-RUN'} | SINGLE-ROLL targets: ${rows.length}`);

const Q=`query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id variants(first:30){ nodes { id title sku price } } } } }`;
const M=`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){ productVariantsBulkUpdate(productId:$pid,variants:$variants){ userErrors{ field message } } }`;
const b={fix:[],already_priced:[],anomaly:[],no_roll:[],missing:[]};
for(let i=0;i<rows.length;i+=50){
  const batch=rows.slice(i,i+50);
  const data=await gql(Q,{ids:batch.map(x=>x.gid)});
  const byId=new Map(batch.map(x=>[x.gid,x]));
  const got=new Set();
  for(const p of data.nodes){ if(!p)continue; got.add(p.id);
    const t=byId.get(p.id); const rolls=p.variants.nodes.filter(v=>!isSample(v));
    if(!rolls.length){b.no_roll.push(t);continue;}
    const roll=rolls.reduce((a,c)=>+c.price>+a.price?c:a);
    const cur=+roll.price; const rec={pid:p.id,vid:roll.id,mfr:t.mfr,cur,to:t.retail};
    if(Math.abs(cur-t.retail)<0.01){b.already_priced.push(rec);continue;}
    if(cur<=10){b.fix.push(rec);}                                  // $0/$4.25 garbage -> price it
    else if(t.retail/cur>2.5||t.retail/cur<0.4){b.anomaly.push(rec);} // real price, suspicious -> skip
    else{b.already_priced.push(rec);}                              // real price, close enough -> leave
  }
  for(const x of batch) if(!got.has(x.gid)) b.missing.push(x);
  if(i%500===0&&i)process.stderr.write(`  scanned ${i}/${rows.length}\n`);
}
console.log(`  fix garbage ($0/$4.25) -> retail : ${b.fix.length}`);
console.log(`  already real-priced (leave)      : ${b.already_priced.length}`);
console.log(`  ⚠ anomaly (skip, real price)     : ${b.anomaly.length}`);
console.log(`  no roll variant (skip)           : ${b.no_roll.length}`);
console.log(`  not found on Shopify (skip)      : ${b.missing.length}`);
console.log('  sample fixes:'); for(const r of b.fix.slice(0,6))console.log(`    ${r.mfr}: $${r.cur} -> $${r.to}`);
fs.writeFileSync('data/price-sheets/schu-roll-price-plan.json',JSON.stringify({fix:b.fix,anomaly:b.anomaly},null,2));
if(!APPLY){console.log('\nDRY-RUN only. To execute: node push-schu-roll-prices.mjs --apply --i-am-steve');process.exit(0);}

console.log('\n⚠️  LIVE APPLY — pricing SINGLE-ROLL wallcoverings…');
let ok=0,err=0;
for(const r of b.fix){
  try{const d=await gql(M,{pid:r.pid,variants:[{id:r.vid,price:r.to.toFixed(2)}]});
    const ue=d.productVariantsBulkUpdate?.userErrors||[];if(ue.length){err++;if(err<=10)console.log('  err',r.mfr,JSON.stringify(ue));}else ok++;
  }catch(e){err++;if(err<=10)console.log('  EX',r.mfr,e.message);}
  if((ok+err)%200===0)console.log(`  progress ${ok} ok / ${err} err`);
}
console.log(`\nDONE — ${ok} roll prices set, ${err} errors.`);