← 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}`);