← back to Dw Yolo Loop
scripts/price-sheets/add-roll-variant-oddball.mjs
80 lines
#!/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.`);