← back to Dw Yolo Loop
Add Schumacher roll-price pusher + propagate price-sheet costs to Kamatera canonical (1,174 costed)
1193fa79f6893b83247a75f70d73f1744a390577 · 2026-06-15 10:41:27 -0700 · Steve Abrams
Files touched
A scripts/price-sheets/push-schu-roll-prices.mjs
Diff
commit 1193fa79f6893b83247a75f70d73f1744a390577
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 15 10:41:27 2026 -0700
Add Schumacher roll-price pusher + propagate price-sheet costs to Kamatera canonical (1,174 costed)
---
scripts/price-sheets/push-schu-roll-prices.mjs | 70 ++++++++++++++++++++++++++
1 file changed, 70 insertions(+)
diff --git a/scripts/price-sheets/push-schu-roll-prices.mjs b/scripts/price-sheets/push-schu-roll-prices.mjs
new file mode 100644
index 0000000..cc80124
--- /dev/null
+++ b/scripts/price-sheets/push-schu-roll-prices.mjs
@@ -0,0 +1,70 @@
+#!/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.`);
← 8965036 Resolve staged price-sheet costs into shopify_products.cost
·
back to Dw Yolo Loop
·
Kravet dedup sweep: bulk full-catalog true-dup detector (ven e3ad9a1 →