← back to Dw Yolo Loop
scripts/kravet-master-2026/add_roll_variants.mjs
106 lines
#!/usr/bin/env node
/* Add the missing "Sold Per Roll" variant @ 2026 MAP to sample-only Kravet-family
WALLCOVERING products (DTD verdict A 3/3, Steve "go 305" 2026-06-16).
- Reads /tmp/kravet_roll_dryrun.csv (SAMPLE_ONLY rows) + Kamatera new_map.
- Per product: derive roll sku = sample sku minus -Sample; width from custom.width
metafield; option "Size" value = "Sold Per Roll - {W}In Wide".
- IDEMPOTENT: skips any product that already has a Sold-Per-Roll variant.
- Mirrors the store template: inventoryPolicy CONTINUE, qty 2026 @ the one location.
Flags: --apply (default dry-run, no writes) | --limit N (canary).
Token: SHOPIFY_ADMIN_TOKEN. */
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
// Inputs are committed next to this script (reproducible across reboots since
// /tmp is ephemeral); fall back to the original /tmp paths for legacy runs.
const __dir=path.dirname(fileURLToPath(import.meta.url));
const resolveInput=(name,legacy)=>{ const repo=path.join(__dir,'inputs',name); return fs.existsSync(repo)?repo:legacy; };
const SRC_FILE=resolveInput('kravet_305_source.txt','/tmp/kravet_305_source.txt');
const CSV_FILE=resolveInput('kravet_roll_dryrun.csv','/tmp/kravet_roll_dryrun.csv');
const STORE='designer-laboratory-sandbox.myshopify.com';
const TOKEN=process.env.SHOPIFY_ADMIN_TOKEN;
const LOCATION='gid://shopify/Location/5795643504';
const APPLY=process.argv.includes('--apply');
const li=process.argv.indexOf('--limit'); const LIMIT=li>=0?parseInt(process.argv[li+1]):Infinity;
if(!TOKEN){ console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const log=s=>{ console.log(s); fs.appendFileSync('/tmp/add_roll_variants.log',s+'\n'); };
// new_map + unit per gid from Kamatera (source file built earlier)
// cols: gid | vendor | sku | new_map | unit_of_measure | ... | product_type
const nm={}, unitOf={};
for(const line of fs.readFileSync(SRC_FILE,'utf8').trim().split('\n')){
const c=line.split('|'); if(c.length>=5){ nm[c[0]]=parseFloat(c[3]); unitOf[c[0]]=(c[4]||'').trim().toUpperCase(); }
}
// the 305 sample-only gids
// PRICE FLOOR: a ROLL MAP below this is almost certainly a sample/per-yard/garbage
// value (the $4.25 sample-as-price trap). Reject + log rather than write it live.
const MIN_ROLL_PRICE=10;
const rawTargets=fs.readFileSync(CSV_FILE,'utf8').trim().split('\n')
.filter(l=>l.includes('SAMPLE_ONLY_NEEDS_PRICE')).map(l=>{const c=l.split(','); return {gid:c[0],vendor:c[1],sku:c[2],new_map:nm[c[0]],unit:unitOf[c[0]]};});
// UNIT GUARD (standing rule kravet-map-table-mixes-yard-and-roll-units): only
// write per-roll MAP onto these per-roll products. A per-yard MAP here would be
// ~3x underpriced — drop + log anything not explicitly ROLL.
const unitDropped=rawTargets.filter(t=>t.unit!=='ROLL');
const priceDropped=rawTargets.filter(t=>t.unit==='ROLL' && (!(t.new_map>=MIN_ROLL_PRICE) || t.new_map===4.25));
const targets=rawTargets.filter(t=>t.unit==='ROLL' && t.new_map>=MIN_ROLL_PRICE && t.new_map!==4.25);
async function gql(query,variables){
for(let a=0;a<4;a++){
const r=await fetch(`https://${STORE}/admin/api/2024-10/graphql.json`,{method:'POST',
headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query,variables})});
const j=await r.json();
if(j.errors && JSON.stringify(j.errors).includes('Throttled')){ await sleep(2000); continue; }
return j;
}
throw new Error('throttled out');
}
const READ=`query($id:ID!){ product(id:$id){ id title options{ name }
width:metafield(namespace:"custom",key:"width"){ value }
variants(first:30){ edges{ node{ title sku } } } } }`;
const CREATE=`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkCreate(productId:$pid, variants:$variants){
productVariants{ id title sku price selectedOptions{ name value } }
userErrors{ field message } } }`;
let added=0,skipped=0,failed=0,n=0;
log(`\n=== add_roll_variants ${APPLY?'APPLY':'DRY-RUN'} limit=${LIMIT} targets=${targets.length} (dropped: non-ROLL ${unitDropped.length}, below-floor ${priceDropped.length}) @ ${new Date().toISOString?.()||'now'} ===`);
for(const d of unitDropped) log(`DROP non-ROLL-unit ${d.vendor} ${d.sku} unit=${d.unit||'∅'} new_map=${d.new_map}`);
for(const d of priceDropped) log(`DROP below-floor ${d.vendor} ${d.sku} new_map=${d.new_map}`);
for(const t of targets){
if(n>=LIMIT) break; n++;
let p;
try{ const d=await gql(READ,{id:t.gid}); p=d.data?.product; }catch(e){ failed++; log(`FAIL read ${t.gid} ${e.message}`); continue; }
if(!p){ failed++; log(`FAIL no-product ${t.gid}`); continue; }
const vs=p.variants.edges.map(e=>e.node);
if(vs.some(v=>/Sold Per Roll/i.test(v.title))){ skipped++; log(`SKIP has-roll ${p.title}`); continue; }
// require a real "-Sample" variant: the roll SKU is derived from it. With no
// -Sample match the old `|| vs[0]` fallback minted a roll variant sharing the
// sample's SKU — skip + log instead so it's surfaced, not silently mislabeled.
const sample=vs.find(v=>/-sample$/i.test(v.sku||''));
if(!sample){ skipped++; log(`SKIP no-sample-sku ${p.title} (skus: ${vs.map(v=>v.sku||'∅').join(',')})`); continue; }
const rollSku=(sample.sku||'').replace(/-Sample$/i,'');
const wm=(p.width?.value||'').match(/[\d.]+/); // leading numeric part only ("20 in (50.8 cm)" -> "20")
const w=wm?wm[0]:'';
const wn=parseFloat(w);
const label=(wn>=12 && wn<=60) ? `Sold Per Roll - ${w}In Wide` : `Sold Per Roll`;
const price=t.new_map.toFixed(2);
const variant={ price,
optionValues:[{ optionName:(p.options[0]?.name||'Size'), name:label }],
inventoryItem:{ sku:rollSku, tracked:true },
inventoryPolicy:'CONTINUE',
inventoryQuantities:[{ locationId:LOCATION, availableQuantity:2026 }] };
if(!APPLY){ log(`DRY ${p.title} | +${label} @ $${price} sku=${rollSku}`); added++; continue; }
try{
const d=await gql(CREATE,{pid:t.gid,variants:[variant]});
const ue=d.data?.productVariantsBulkCreate?.userErrors||[];
if(ue.length){ failed++; log(`FAIL ${p.title} :: ${JSON.stringify(ue)}`); }
else { added++; const nv=d.data.productVariantsBulkCreate.productVariants[0];
log(`OK ${p.title} | ${nv.title} $${nv.price} sku=${nv.sku}`); }
}catch(e){ failed++; log(`FAIL create ${p.title} ${e.message}`); }
await sleep(700);
if(n%25===0) log(`-- progress ${n}/${Math.min(LIMIT,targets.length)} added=${added} skip=${skipped} fail=${failed}`);
}
log(`\n=== DONE ${APPLY?'APPLY':'DRY'} added=${added} skipped=${skipped} failed=${failed} of ${n} processed ===`);