← back to Dw Add Sellable Variant Tk10902

add-variant.mjs

186 lines

#!/usr/bin/env node
// TK-10902 / TK-10875 — Add a sellable product variant to ACTIVE DW products that have ONLY a $4.25 sample.
// Steve-APPROVED. Reversible + ledgered. Verify-before-write. NO EMAIL. $0 (Shopify Admin API).
//
// For each candidate:
//   1. Re-read the LIVE product. HARD-STOP (skip+log) on any drift:
//        - exactly 1 variant
//        - that variant SKU ends in -sample (case-insensitive) at $4.25, option value == 'Sample'
//   2. Derive sellable SKU = sample SKU with the -sample suffix stripped.
//        - HARD-STOP if derived SKU is blank, unchanged, or already used by another live variant/product.
//   3. POST a NEW variant on the product's existing single option axis:
//        option value = candidate.sellable_label, price = candidate.final_price,
//        inventory_management=null (no tracking), inventory_policy='continue'.
//        (Existing sample variant is left untouched.)
//   4. Ledger the created variant_id + product_id + exact delete undo command.
//
// Usage: node add-variant.mjs <candidates.json> [--limit N] [--live]
//   default = DRY-RUN (no writes). --live performs the writes.

import fs from 'fs';
import { execSync } from 'child_process';

const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const TOKEN = execSync(`grep -E '^SHOPIFY_ADMIN_TOKEN=' ${process.env.HOME}/Projects/secrets-manager/.env | cut -d= -f2-`).toString().trim();
if (!TOKEN) { console.error('NO SHOPIFY TOKEN'); process.exit(1); }

const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
const RUNLOG = `${process.cwd()}/run-log.jsonl`;

const args = process.argv.slice(2);
const file = args[0];
const LIVE = args.includes('--live');
const limIdx = args.indexOf('--limit');
const LIMIT = limIdx >= 0 ? parseInt(args[limIdx + 1], 10) : Infinity;

const base = `https://${DOMAIN}/admin/api/${API}`;
const hdr = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };

const sleep = ms => new Promise(r => setTimeout(r, ms));

async function shopify(path, opts = {}) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(`${base}${path}`, { headers: hdr, ...opts });
    if (res.status === 429) { await sleep(2500); continue; }
    const body = await res.text();
    let json; try { json = JSON.parse(body); } catch { json = { _raw: body }; }
    return { status: res.status, json };
  }
  return { status: 429, json: { error: 'rate-limited' } };
}

function stripSampleSuffix(sku) {
  // strip a trailing -sample / -Sample / -SAMPLE (only the exact suffix)
  return sku.replace(/-sample$/i, '');
}

function appendJsonl(path, obj) {
  fs.mkdirSync(path.substring(0, path.lastIndexOf('/')), { recursive: true });
  fs.appendFileSync(path, JSON.stringify(obj) + '\n');
}

const candidates = JSON.parse(fs.readFileSync(file, 'utf8'));
const batch = candidates.slice(0, LIMIT);

let built = 0, skipped = 0;
const skips = [];

console.log(`\n=== ${LIVE ? 'LIVE' : 'DRY-RUN'} — ${batch.length} candidates (of ${candidates.length}) ===\n`);

for (const c of batch) {
  const pid = c.product_id;
  // 1. Re-read live
  const { status, json } = await shopify(`/products/${pid}.json`);
  if (status !== 200 || !json.product) {
    skipped++; skips.push({ pid, reason: `fetch_failed_${status}`, vendor: c.vendor });
    console.log(`SKIP ${pid} (${c.vendor}) fetch_failed_${status}`);
    continue;
  }
  const p = json.product;
  const variants = p.variants || [];
  const options = p.options || [];

  // Guard: exactly one variant
  if (variants.length !== 1) {
    skipped++; skips.push({ pid, reason: `variant_count=${variants.length}`, vendor: c.vendor, title: p.title });
    console.log(`SKIP ${pid} (${c.vendor}) variant_count=${variants.length}`);
    continue;
  }
  const sv = variants[0];
  // Guard: the lone variant is a genuine sample at $4.25 on option value 'Sample'
  const svSku = (sv.sku || '').trim();
  const svPrice = parseFloat(sv.price);
  const svOpt = (sv.option1 || '').trim();
  if (!/-sample$/i.test(svSku) || svPrice !== 4.25 || svOpt.toLowerCase() !== 'sample') {
    skipped++; skips.push({ pid, reason: `not_clean_sample sku=${svSku} price=${svPrice} opt=${svOpt}`, vendor: c.vendor, title: p.title });
    console.log(`SKIP ${pid} (${c.vendor}) not_clean_sample sku=${svSku} price=${svPrice} opt=${svOpt}`);
    continue;
  }
  // Guard: single option axis
  if (options.length !== 1) {
    skipped++; skips.push({ pid, reason: `option_count=${options.length}`, vendor: c.vendor, title: p.title });
    console.log(`SKIP ${pid} (${c.vendor}) option_count=${options.length}`);
    continue;
  }
  const optName = options[0].name;

  // 2. Derive sellable SKU (self-copy, never mint)
  const sellSku = stripSampleSuffix(svSku);
  if (!sellSku || sellSku === svSku) {
    skipped++; skips.push({ pid, reason: `sku_strip_failed from=${svSku}`, vendor: c.vendor, title: p.title });
    console.log(`SKIP ${pid} (${c.vendor}) sku_strip_failed from=${svSku}`);
    continue;
  }
  // Guard: sellable SKU not already used on THIS product
  if (variants.some(v => (v.sku || '').trim().toLowerCase() === sellSku.toLowerCase())) {
    skipped++; skips.push({ pid, reason: `sellable_sku_exists_on_product ${sellSku}`, vendor: c.vendor, title: p.title });
    console.log(`SKIP ${pid} (${c.vendor}) sellable_sku_exists ${sellSku}`);
    continue;
  }
  // Guard: option value not already present
  if (options[0].values.some(val => val.toLowerCase() === c.sellable_label.toLowerCase())) {
    skipped++; skips.push({ pid, reason: `option_value_exists ${c.sellable_label}`, vendor: c.vendor, title: p.title });
    console.log(`SKIP ${pid} (${c.vendor}) option_value_exists ${c.sellable_label}`);
    continue;
  }

  const price = Number(c.final_price).toFixed(2);
  // Guard: price sanity
  if (!(parseFloat(price) > 4.25)) {
    skipped++; skips.push({ pid, reason: `bad_price ${price}`, vendor: c.vendor, title: p.title });
    console.log(`SKIP ${pid} (${c.vendor}) bad_price ${price}`);
    continue;
  }

  const plan = {
    pid, vendor: c.vendor, product_type: c.product_type, title: p.title,
    optName, sample_sku: svSku, sellable_sku: sellSku,
    sellable_label: c.sellable_label, price, basis: c.price_basis, cost: c.cost
  };

  if (!LIVE) {
    console.log(`WOULD BUILD ${pid} (${c.vendor}) ${sellSku} @ $${price} [${c.price_basis}] opt="${optName}":"${c.sellable_label}" (sample ${svSku} $4.25 kept)`);
    built++;
    appendJsonl(RUNLOG, { ts: new Date().toISOString(), mode: 'dry', action: 'would_build', ...plan });
    continue;
  }

  // 3. Create the variant (REST preserves existing variants)
  const payload = {
    variant: {
      option1: c.sellable_label,
      price: price,
      sku: sellSku,
      inventory_management: null,
      inventory_policy: 'continue',
      taxable: sv.taxable !== undefined ? sv.taxable : true
    }
  };
  const cr = await shopify(`/products/${pid}/variants.json`, { method: 'POST', body: JSON.stringify(payload) });
  if (cr.status !== 201 && cr.status !== 200) {
    skipped++; skips.push({ pid, reason: `create_failed_${cr.status} ${JSON.stringify(cr.json).slice(0,200)}`, vendor: c.vendor, title: p.title });
    console.log(`SKIP ${pid} (${c.vendor}) create_failed_${cr.status} ${JSON.stringify(cr.json).slice(0,160)}`);
    continue;
  }
  const nv = cr.json.variant;
  built++;
  const undo = `curl -s -X DELETE "https://${DOMAIN}/admin/api/${API}/products/${pid}/variants/${nv.id}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN"`;
  const ledgerEntry = {
    ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10902',
    action: `added sellable variant ${sellSku} @ $${price} [${c.price_basis}] to product ${pid} (${c.vendor})`,
    product_id: pid, variant_id: nv.id, variant_sku: sellSku, price, basis: c.price_basis,
    sample_variant_preserved: svSku,
    blast_radius: 1, undo_cmd: undo,
    verify: `curl -s "https://${DOMAIN}/admin/api/${API}/products/${pid}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" | jq '.product.variants[]|{sku,option1,price}'`
  };
  appendJsonl(LEDGER, ledgerEntry);
  appendJsonl(RUNLOG, { ts: new Date().toISOString(), mode: 'live', action: 'built', variant_id: nv.id, ...plan });
  console.log(`BUILT ${pid} (${c.vendor}) variant ${nv.id} sku=${sellSku} @ $${price} [${c.price_basis}]`);

  await sleep(700); // gentle pacing between individual creates
}

fs.writeFileSync(`${process.cwd()}/skips.json`, JSON.stringify(skips, null, 1));
console.log(`\n=== DONE: built=${built} skipped=${skipped} (skips -> skips.json) ===`);