← back to Dw Add Sellable Variant Tk10902
tk10875-130/repair.mjs
80 lines
#!/usr/bin/env node
// TK-10875-130 — SCOPED variant-repair for RR-123 + Cole&Son-2 ONLY.
// Defect: lone `-Sample`-SKU variant holds the FULL ROLL retail price (price inverted).
// Fix -> proper 2-variant shape:
// (a) rename the lone variant into the ROLL variant: sku {base}-Sample -> {base},
// option value -> "Sold Per Roll", KEEP the retail price, inventory_policy continue;
// (b) ADD a Sample variant: sku {base}-Sample, $4.25, option "Sample", inventory NOT tracked.
// Reversibility-FIRST: prestate saved BEFORE writing; undo = revert roll variant + delete added sample.
// HARD-STOP per product on any drift vs defect-state.json. Thibaut hard-excluded (none in set).
// Usage: node repair.mjs [--only=<pid>] [--live] default = dry-run.
import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
import { execSync } from 'child_process';
const TOKEN=execSync(`grep -E '^SHOPIFY_ADMIN_TOKEN=' ${process.env.HOME}/Projects/secrets-manager/.env|cut -d= -f2-`).toString().trim();
const DOMAIN='designer-laboratory-sandbox.myshopify.com', API='2024-10';
const hdr={'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'};
const LIVE=process.argv.includes('--live');
const ONLY=(process.argv.find(a=>a.startsWith('--only='))||'').split('=')[1];
const REST=`https://${DOMAIN}/admin/api/${API}`;
const dataDir=new URL('./data',import.meta.url).pathname; mkdirSync(dataDir,{recursive:true});
const PRESTATE=`${dataDir}/prestate.jsonl`;
const state=JSON.parse(readFileSync(`${dataDir}/defect-state.json`,'utf8'));
let list=state.products.filter(p=>p.ok);
if(ONLY) list=list.filter(p=>String(p.pid)===String(ONLY));
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function api(path,opts={}){for(let a=0;a<5;a++){const res=await fetch(`${REST}${path}`,{headers:hdr,...opts});if(res.status===429){await sleep(2500);continue;}const t=await res.text();let j;try{j=JSON.parse(t);}catch{j={_raw:t};}return{status:res.status,json:j};}return{status:429,json:{}};}
let fixed=0, skip=0;
for(const c of list){
const pid=c.pid;
// HARD re-read live + re-guard (never trust the snapshot at write time)
const {status,json}=await api(`/products/${pid}.json`);
if(status!==200||!json.product){console.log('SKIP',pid,'fetch',status);skip++;continue;}
const p=json.product, vs=p.variants||[], opts=p.options||[];
if(vs.length!==1||opts.length!==1){console.log('SKIP',pid,`drift nvars=${vs.length} opts=${opts.length}`);skip++;continue;}
const v=vs[0]; const sku=(v.sku||'').trim(); const price=parseFloat(v.price);
if(!/-sample$/i.test(sku)||!(price>5)){console.log('SKIP',pid,`drift sku=${sku} price=${price}`);skip++;continue;}
const base=sku.replace(/-sample$/i,'');
if(!base||base===sku){console.log('SKIP',pid,'strip_failed',sku);skip++;continue;}
if(vs.some(x=>(x.sku||'').toLowerCase()===base.toLowerCase())){console.log('SKIP',pid,'roll_sku_collides',base);skip++;continue;}
const optName=opts[0].name;
const plan={pid,vendor:c.vendor,title:p.title,optName,loneVariantId:v.id,
from:{sku,price:v.price,option1:v.option1,inv_pol:v.inventory_policy,inv_mgmt:v.inventory_management},
rollSku:base, rollLabel:'Sold Per Roll', rollPrice:v.price, sampleSku:sku, samplePrice:'4.25'};
if(!LIVE){console.log(`DRY ${pid} (${c.vendor}) ${p.title}`);
console.log(` lone[${v.id}] sku ${sku} $${v.price} opt"${v.option1}" -> ROLL sku ${base} $${v.price} opt"Sold Per Roll"`);
console.log(` + NEW Sample sku ${sku} $4.25 opt"Sample" (untracked)`);
fixed++; continue;}
// reversibility FIRST
appendFileSync(PRESTATE, JSON.stringify({ts:new Date().toISOString(),...plan})+'\n');
// STEP 1: rename lone variant -> ROLL (PUT variant). Keep price. option1 -> "Sold Per Roll", sku -> base.
const put=await api(`/variants/${v.id}.json`,{method:'PUT',body:JSON.stringify({variant:{
id:v.id, sku:base, option1:'Sold Per Roll', price:v.price, inventory_policy:'continue'}})});
if(put.status!==200){console.log('ERR',pid,'roll-rename',put.status,JSON.stringify(put.json).slice(0,160));skip++;continue;}
// STEP 2: add Sample variant
const post=await api(`/products/${pid}/variants.json`,{method:'POST',body:JSON.stringify({variant:{
option1:'Sample', price:'4.25', sku:sku, inventory_management:null, inventory_policy:'continue',
taxable: v.taxable!==undefined?v.taxable:true}})});
if(post.status!==201&&post.status!==200){console.log('ERR',pid,'sample-add',post.status,JSON.stringify(post.json).slice(0,160));
console.log(' ^^^ roll-rename succeeded but sample-add failed — see prestate.jsonl to revert',pid);skip++;continue;}
const nv=post.json.variant;
// ledger
const undo=`# revert lone variant to sample + delete added sample variant\n`+
`curl -s -X PUT "${REST}/variants/${v.id}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" -H "Content-Type: application/json" -d '${JSON.stringify({variant:{id:v.id,sku:sku,option1:v.option1||'Default Title',price:v.price,inventory_policy:v.inventory_policy||'deny'}})}' ; `+
`curl -s -X DELETE "${REST}/products/${pid}/variants/${nv.id}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN"`;
appendFileSync(`${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`, JSON.stringify({
ts:new Date().toISOString(), agent:'vp-dw-commerce', ticket:'TK-10875-130',
action:`variant-repair ${c.vendor} ${pid}: rename lone sample-SKU (\$${v.price}) -> Roll sku ${base}, add Sample \$4.25 sku ${sku}`,
product_id:pid, roll_variant_id:v.id, added_sample_variant_id:nv.id, blast_radius:1,
undo_cmd:undo, verify:`curl -s "${REST}/products/${pid}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN"|jq '.product.variants[]|{sku,option1,price}'`
})+'\n');
fixed++; console.log(`FIXED ${pid} (${c.vendor}) roll ${base} \$${v.price} + sample ${sku} \$4.25`);
await sleep(700);
}
console.log(`\n${LIVE?'LIVE':'DRY'} — fixed ${fixed}, skipped ${skip}`);