← back to Dw Yolo Loop
scripts/price-sheets/add-roll-variant.mjs
85 lines
#!/usr/bin/env node
/**
* add-roll-variant.mjs — for the 532 dark single-variant Schumacher products whose only
* variant is Size:"Sample" @ $4.25, ADD a real Size:"Roll" variant priced at DW retail
* (cost/0.65/0.85) while KEEPING the $4.25 memo sample. (Steve's choice, 2026-06-15.)
*
* Standing rule: new variants get inventory=2026 on BOTH variants (sample + roll), set at
* the primary location. REQUIRES a token with read_locations + write_inventory in addition
* to write_products. If location read fails, the script REFUSES to run (won't half-build).
*
* Targets: /tmp/schu-addroll-targets.csv = productGid,sampleVarGid,sampleSku,rollSku,retail,cost
* Reversible: records created roll-variant ids to data/price-sheets/addroll-created.json
* (revert = productVariantsBulkDelete those ids).
*
* USAGE: node add-roll-variant.mjs --limit=2 # dry-run, 2 products
* node add-roll-variant.mjs --limit=2 --apply --i-am-steve # LIVE test on 2
* node add-roll-variant.mjs --apply --i-am-steve # LIVE all 532
*/
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 targets=fs.readFileSync('/tmp/schu-addroll-targets.csv','utf8').trim().split('\n').map(l=>{const[pid,svid,ssku,rsku,retail,cost]=l.split(',');return{pid,svid,ssku,rsku,retail:+retail,cost:+cost};});
// idempotent: skip products that already got a roll variant (recorded in created json)
let donePids=new Set();
try{donePids=new Set(JSON.parse(fs.readFileSync('data/price-sheets/addroll-created.json','utf8')).map(c=>c.pid));}catch{}
const skipDone=targets.filter(t=>donePids.has(t.pid)).length;
targets=targets.filter(t=>!donePids.has(t.pid));
if(skipDone)console.log(`(skipping ${skipDone} already-created)`);
if(Number.isFinite(LIMIT))targets=targets.slice(0,LIMIT);
console.log(`add-roll-variant — mode: ${APPLY?'⚠️ LIVE APPLY':'DRY-RUN'} | targets: ${targets.length} (of 532)`);
// preflight: location read (proves the new token has the scopes). REFUSE if it fails.
const loc=await gql(`{locations(first:5){nodes{id}}}`);
if(loc?.__err){
console.error('\n⛔ token cannot read locations — needs read_locations + write_inventory.');
console.error(' '+JSON.stringify(loc.__err).slice(0,200));
console.error(' Route the upgraded token via the secrets skill, then re-run. Refusing to half-build.');
process.exit(2);
}
const LOCATION=loc.locations.nodes[0].id;
console.log(`primary location: ${LOCATION}`);
if(!APPLY){
console.log('\nDRY-RUN plan (first 6):');
for(const t of targets.slice(0,6))console.log(` ${t.ssku}: keep Sample $4.25 + ADD Roll ${t.rsku} @ $${t.retail} (inv ${INV_QTY} both)`);
console.log(`\nWould add ${targets.length} roll variants. To execute: node add-roll-variant.mjs ${Number.isFinite(LIMIT)?`--limit=${LIMIT} `:''}--apply --i-am-steve`);
process.exit(0);
}
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}}}}`;
// accumulate created records across runs (don't clobber prior runs' revert log)
let created=[]; try{created=JSON.parse(fs.readFileSync('data/price-sheets/addroll-created.json','utf8'));}catch{}
let ok=0,err=0,invok=0,inverr=0;
for(const t of targets){
// 1) add the Roll variant
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'}]}]});
if(d?.__err){err++;if(err<=10)console.log(' ERR',t.ssku,JSON.stringify(d.__err).slice(0,160));continue;}
const ue=d.productVariantsBulkCreate?.userErrors||[];
if(ue.length){err++;if(err<=10)console.log(' uerr',t.ssku,JSON.stringify(ue));continue;}
const rollVar=d.productVariantsBulkCreate.productVariants[0];
created.push({pid:t.pid,rollVid:rollVar.id,rollSku:rollVar.sku}); ok++;
// 2) inventory=2026 on BOTH variants
const sii=(await gql(QII,{id:t.svid}))?.node?.inventoryItem?.id;
const items=[rollVar.inventoryItem?.id, sii].filter(Boolean);
const setq=items.map(id=>({inventoryItemId:id,locationId:LOCATION,quantity:INV_QTY}));
const di=await gql(INV,{input:{reason:'correction',setQuantities:setq}});
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,140));}else invok++;
if((ok+err)%100===0)console.log(` progress: ${ok} created / ${err} err / inv ${invok} ok ${inverr} err`);
fs.writeFileSync('data/price-sheets/addroll-created.json',JSON.stringify(created,null,2));
}
console.log(`\nDONE — ${ok} roll variants added, ${err} errors | inventory set: ${invok} ok, ${inverr} err`);
console.log('reversible: data/price-sheets/addroll-created.json (productVariantsBulkDelete to undo)');