← back to Dw Unbuyable Recovery Pilot

tk10978-momentum/repair.mjs

125 lines

#!/usr/bin/env node
// TK-10978 — Scalamandre ADD-ROLL recovery (customer-facing Shopify write).
//
// Scalamandre "unbuyable" products are GENUINELY sample-only: a single variant
// option1='Sample' @ $4.25 (live-confirmed 3/3, then again here per product).
// Recovery = ADD a sellable "Sold Per Yard" variant at reconciled retail, and
// LEAVE the $4.25 Sample untouched. (Distinct from the WG RR-defect, which
// RELABELED a mispriced lone Sample. Here we ADD — we never touch the sample.)
//
// VERIFY-BEFORE-ACT (per product, live, at write time — never trust the plan):
//   1. GET product live; must be exactly 1 variant.
//   2. lone variant option1 == 'Sample' (case-insensitive).
//   3. lone variant price <= 4.50 (genuine sample; NOT the RR-defect).
//   4. lone variant SKU == expected sampleSku from the plan (right product).
//   5. derived rollSku not already present as any variant SKU (idempotent / no double-add).
//   6. match sanity: plan row's matchOk true (title pattern+color == catalog).
//   7. SANITY-RANGE on retail: 10 < rollRetail < 10000 (guard NULL/decimal mispricing).
// Any guard fail -> SKIP + log; never a partial/ambiguous write.
//
// Recovery action: POST a new variant {option1:'Sold Per Roll', price:rollRetail,
//   sku:rollSku, inventory_policy:'deny', inventory_management:'shopify'}.
// Reversibility FIRST: prestate.jsonl written BEFORE the write; undo = DELETE the
//   added roll variant (the sample is untouched). Ledgered per product.
//
// Usage: node repair.mjs [--only=<pid>] [--limit=N] [--live]   default = dry-run.
import { readFileSync, appendFileSync, writeFileSync, 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 REST = `https://${DOMAIN}/admin/api/${API}`;
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 LIMIT = Number((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || 0);

const dataDir = new URL('./data', import.meta.url).pathname; mkdirSync(dataDir, { recursive: true });
const PRESTATE = `${dataDir}/prestate.jsonl`;
const SKIPS = `${dataDir}/skips.json`;
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;

const { plan } = JSON.parse(readFileSync(`${dataDir}/plan.json`, 'utf8'));
let list = plan;
if (ONLY) list = list.filter(p => String(p.pid) === String(ONLY));
if (LIMIT > 0) list = list.slice(0, LIMIT);

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;

  // GUARD 6 (from plan) + 7 (range) — cheap, before any network
  if (!c.matchOk) { console.log('SKIP', pid, 'match-sanity false'); skips.push({ pid, reason: 'match_sanity_false', title: c.title }); skipped++; continue; }
  if (!(c.rollRetail > 10 && c.rollRetail < 10000)) { console.log('SKIP', pid, `retail out of range ${c.rollRetail}`); skips.push({ pid, reason: `retail_range ${c.rollRetail}`, title: c.title }); skipped++; continue; }

  // HARD re-read live + re-guard
  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 || [];

  if (vs.length !== 1) { console.log('SKIP', pid, `nvars=${vs.length} (already restructured?)`); skips.push({ pid, reason: `nvars=${vs.length}`, title: p.title }); 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}`, title: p.title }); skipped++; continue; }
  if (!(price <= 4.5)) { console.log('SKIP', pid, `lone price=${price} (>4.50 = not genuine sample / RR-defect)`); skips.push({ pid, reason: `price=${price}`, title: p.title }); skipped++; continue; }
  if (sku.toLowerCase() !== String(c.sampleSku).toLowerCase()) { console.log('SKIP', pid, `lone sku='${sku}' != expected '${c.sampleSku}'`); skips.push({ pid, reason: `sku_mismatch ${sku}`, title: p.title }); skipped++; continue; }
  // GUARD 5: roll SKU must NOT already exist as a variant SKU (idempotent / no double-add)
  if (vs.some(x => (x.sku || '').toLowerCase() === String(c.rollSku).toLowerCase())) { console.log('SKIP', pid, `roll sku ${c.rollSku} already present`); skips.push({ pid, reason: `roll_sku_present ${c.rollSku}`, title: p.title }); skipped++; continue; }

  const rollSku = c.rollSku;
  const rollPrice = c.rollRetail.toFixed(2);

  if (!LIVE) {
    console.log(`DRY ${pid} ${p.title}`);
    console.log(`     keep Sample[${v.id}] sku ${sku} $${v.price} (untouched)`);
    console.log(`     + ADD "Sold Per Yard" sku ${rollSku} $${rollPrice} (continue=buyable)  [catalog ${c.catalog_dw_sku}, trade $${c.price_trade}]`);
    acted++; continue;
  }

  // reversibility FIRST
  const preRec = { ts: new Date().toISOString(), pid, title: p.title, sampleVariantId: v.id, sampleSku: sku, rollSku, rollPrice, catalog_dw_sku: c.catalog_dw_sku };
  appendFileSync(PRESTATE, JSON.stringify(preRec) + '\n');

  // ADD the "Sold Per Yard" variant. Preserve the option name the product uses.
  // inventory_policy:'continue' => made-to-order roll is ALWAYS buyable (matches the live
  // Elitis "Sold Per Yard" config). 'deny' + qty0 would leave it UNBUYABLE — the exact
  // defect caught + patched in the pilot.
  const post = await api(`/products/${pid}/variants.json`, { method: 'POST', body: JSON.stringify({ variant: {
    option1: 'Sold Per Yard', price: rollPrice, sku: rollSku,
    inventory_policy: 'continue', inventory_management: 'shopify',
    taxable: v.taxable !== undefined ? v.taxable : true } }) });
  if (post.status !== 201 && post.status !== 200) {
    console.log('ERR', pid, 'roll-add', post.status, JSON.stringify(post.json).slice(0, 200));
    skips.push({ pid, reason: `roll-add ${post.status}`, detail: JSON.stringify(post.json).slice(0, 200), title: p.title }); failed++; continue;
  }
  const nv = post.json.variant;

  const undo = `# delete the added Sold Per Roll variant (the $4.25 Sample was left untouched)\n` +
    `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-10978',
    action: `add-yard PR-Momentum ${pid} (${c.catalog_dw_sku}): ADD "Sold Per Yard" sku ${rollSku} $${rollPrice} (trade $${c.price_trade}); kept $4.25 Sample ${sku} untouched`,
    product_id: pid, added_roll_variant_id: nv.id, sample_variant_id: v.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} + roll ${rollSku} $${rollPrice} (sample ${sku} kept)`);
  await sleep(600); // ~2 req/sec (1 write/product + the GET)
}
writeFileSync(SKIPS, JSON.stringify(skips, null, 2));
console.log(`\n${LIVE ? 'LIVE' : 'DRY'} — acted ${acted}, skipped ${skipped}, failed ${failed} (of ${list.length})`);