← back to Dw Add Sellable Variant Tk10902
tk10875-wolf-gordon/repair.mjs
116 lines
#!/usr/bin/env node
// TK-10875 — Wolf Gordon 224-product variant-restructure (RR-defect fix).
// Mirrors the ledgered TK-10875-130 pattern exactly, adapted for WG:
// The lone variant is option1='Sample', SKU = the numeric DW-SKU (NOT a -Sample-suffixed sku),
// priced at ROLL RETAIL (the defect: no real sample, no sellable roll).
// Fix -> proper 2-variant shape:
// (a) relabel the lone variant: option1 'Sample' -> 'Sold Per Roll', price = computed_roll_retail
// (RECOMPUTED from wolf_gordon_catalog.price_trade/0.65/0.85 — stored in defect-state), KEEP the
// numeric DW-SKU (NO mint), inventory_policy=deny;
// (b) ADD a Sample variant: sku {DW_SKU}-Sample, $4.25, option 'Sample', inventory NOT tracked (continue).
// Reversibility-FIRST: prestate saved BEFORE writing; undo = revert lone variant to option1='Sample' @ old
// price + delete the added sample variant. HARD-STOP per product on any drift vs the RR-defect.
// VERIFY-BEFORE-ACT: GET live first; only act if exactly 1 variant, option1='Sample', price>4.50.
// Idempotent: a product already 2-variant / already relabeled / already $4.25 is SKIPPED.
// Usage: node repair.mjs [--only=<pid>] [--live] default = dry-run.
import { readFileSync, 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 LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.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 < 6; 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 acted = 0, skipped = 0, failed = 0;
const skips = [];
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); skips.push({ pid, reason: `fetch ${status}` }); skipped++; continue; }
const p = json.product, vs = p.variants || [], opts = p.options || [];
// Idempotency / drift guards
if (vs.length !== 1) { console.log('SKIP', pid, `already/other nvars=${vs.length}`); skips.push({ pid, reason: `nvars=${vs.length}` }); skipped++; continue; }
const v = vs[0];
const opt1 = (v.option1 || '').trim();
const sku = (v.sku || '').trim();
const price = parseFloat(v.price);
if (opt1.toLowerCase() !== 'sample') { console.log('SKIP', pid, `lone opt1='${opt1}' (not Sample)`); skips.push({ pid, reason: `opt1=${opt1}` }); skipped++; continue; }
if (!(price > 4.5)) { console.log('SKIP', pid, `price=${price} (already sample-priced?)`); skips.push({ pid, reason: `price=${price}` }); skipped++; continue; }
// sku should be the numeric DW-SKU we expect; guard against surprise
if (sku.toLowerCase() !== String(c.rollSku).toLowerCase()) {
console.log('SKIP', pid, `lone sku='${sku}' != expected '${c.rollSku}'`); skips.push({ pid, reason: `sku_mismatch ${sku}` }); skipped++; continue;
}
const rollSku = c.rollSku; // numeric DW-SKU — NO mint
const sampleSku = c.sampleSku; // {DW_SKU}-Sample
const rollPrice = c.rollRetail.toFixed(2);
const optName = (opts[0] && opts[0].name) || 'Size';
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, rollLabel: 'Sold Per Roll', rollPrice, sampleSku, samplePrice: '4.25',
};
if (!LIVE) {
console.log(`DRY ${pid} (${c.dwSku}) ${p.title}`);
console.log(` lone[${v.id}] sku ${sku} $${v.price} opt"${v.option1}" -> ROLL sku ${rollSku} $${rollPrice} opt"Sold Per Roll" (deny)`);
console.log(` + NEW Sample sku ${sampleSku} $4.25 opt"Sample" (untracked)`);
acted++; continue;
}
// reversibility FIRST
appendFileSync(PRESTATE, JSON.stringify({ ts: new Date().toISOString(), ...plan }) + '\n');
// STEP 1: relabel lone variant -> ROLL. Keep numeric SKU, set price=roll retail, option1='Sold Per Roll', policy=deny.
const put = await api(`/variants/${v.id}.json`, { method: 'PUT', body: JSON.stringify({ variant: {
id: v.id, sku: rollSku, option1: 'Sold Per Roll', price: rollPrice, inventory_policy: 'deny' } }) });
if (put.status !== 200) { console.log('ERR', pid, 'roll-relabel', put.status, JSON.stringify(put.json).slice(0, 160)); skips.push({ pid, reason: `roll-relabel ${put.status}` }); failed++; continue; }
// STEP 2: add Sample variant ($4.25, untracked)
const post = await api(`/products/${pid}/variants.json`, { method: 'POST', body: JSON.stringify({ variant: {
option1: 'Sample', price: '4.25', sku: sampleSku, 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-relabel succeeded but sample-add failed — see prestate.jsonl to revert', pid);
skips.push({ pid, reason: `sample-add ${post.status}` }); failed++; continue;
}
const nv = post.json.variant;
// ledger — concrete undo: revert lone variant to option1='Sample' @ old price + delete added sample
const undo = `# revert lone variant to option1='Sample' @ old price + 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 || 'Sample', 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(LEDGER, JSON.stringify({
ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10875',
action: `variant-restructure Wolf Gordon ${pid} (${c.dwSku}): relabel lone Sample-variant ($${v.price}) -> Sold Per Roll sku ${rollSku} $${rollPrice}, add Sample $4.25 sku ${sampleSku}`,
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');
acted++; console.log(`ACTED ${pid} (${c.dwSku}) roll ${rollSku} $${rollPrice} + sample ${sampleSku} $4.25`);
await sleep(600); // ~2 req/sec REST limit (2 writes/product)
}
import { writeFileSync } from 'node:fs';
writeFileSync(`${dataDir}/skips.json`, JSON.stringify(skips, null, 2));
console.log(`\n${LIVE ? 'LIVE' : 'DRY'} — acted ${acted}, skipped ${skipped}, failed ${failed} (of ${list.length})`);