← back to Dw Unbuyable Recovery Pilot

tk11124-dwve-velvet/apply.mjs

164 lines

#!/usr/bin/env node
/**
 * apply.mjs — TK-11124. Make 18 sample-only ACTIVE "Velvet Walls by Phillipe Romano"
 * (DWVE) products BUYABLE by ADDING one sellable per-yard variant, matching the live
 * working sibling DWVE-430760 exactly. Steve-approved, in-session, customer-facing.
 *
 * Per product (verified live, sample-only, ACTIVE):
 *   • ADD variant Size:"Sold Per Yard" @ $359.00, sku=<DWSKU>-Yard, inventoryPolicy CONTINUE,
 *     inventoryItem.tracked=true  (DEFAULT strategy — the product already has a real "Size"
 *     option w/ value "Sample", so this appends a 2nd option value; the $4.25 Sample is untouched).
 *   • inventoryActivate the new item @ Location/5795643504 (15442 Ventura Blvd) + set on_hand 2026
 *     — REQUIRES write_inventory ⇒ FULL token (lib/shopify.mjs prefers SHOPIFY_FULL_ACCESS_TOKEN …2ea5).
 *   • Align product global.unit_of_measure  "Full Roll" -> "Sold per Yard"  (records old value).
 *   • KEEP the $4.25 Sample variant exactly as-is. Do NOT change product status (stays ACTIVE).
 *
 * SAFETY:
 *   • DRY-RUN by default. --apply --i-am-steve to write (Steve gave explicit in-session approval).
 *   • Idempotent: skips any product that already has a non-sample sellable variant, is not
 *     sample-only, is not ACTIVE, or whose lone variant is the implicit "Default Title" (would
 *     be consumed — never risk the sample).
 *   • Rollback map per product -> out/rollback.jsonl (undo = productVariantsBulkDelete the added
 *     variant + metafieldsSet unit_of_measure back to old value).
 *   • Self-verify AFTER each write: re-query, confirm sample survived AND sellable buyable.
 *   • Ledger each applied product to ~/.claude/yolo-queue/executed-reversible/ledger.jsonl.
 * COST: $0 (local + Shopify Admin API; no metered service).
 */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { gql, SHOP, TOKEN } from '../../designerwallcoverings/scripts/lib/shopify.mjs';

const __dir = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(__dir, 'out');
fs.mkdirSync(outDir, { recursive: true });
const APPLY = process.argv.includes('--apply') && process.argv.includes('--i-am-steve');
const LIMIT = (process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1];

const LOCATION = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd (primary; = getLocation() + sibling)
const QTY = 2026;
const PRICE = '359.00';
const OPT_NAME = 'Size';
const OPT_VALUE = 'Sold Per Yard';           // match sibling DWVE-430760 casing exactly
const UNIT_OLD = 'Full Roll';                 // expected current value (verified)
const UNIT_NEW = 'Sold per Yard';             // line-consistent phrasing ("Sold per Bolt" etc.)

const TARGETS = [
  ['DWVE-429560','6621005643827'],['DWVE-429660','6621005676595'],['DWVE-429760','6621005709363'],
  ['DWVE-429960','6621005774899'],['DWVE-430160','6621005873203'],['DWVE-430260','6621005905971'],
  ['DWVE-430360','6621005938739'],['DWVE-430460','6621005971507'],['DWVE-430560','6621006004275'],
  ['DWVE-430660','6621006069811'],['DWVE-430860','6621006135347'],['DWVE-430960','6621006200883'],
  ['DWVE-431060','6621006233651'],['DWVE-431160','6621006266419'],['DWVE-431260','6621006299187'],
  ['DWVE-431360','6621006331955'],['DWVE-431560','6621006397491'],['DWVE-431660','6621006463027'],
];

const gidP = id => `gid://shopify/Product/${id}`;
const Q = `query($id:ID!){ product(id:$id){ id legacyResourceId title handle status
  options{name values}
  unit: metafield(namespace:"global", key:"unit_of_measure"){ value }
  variants(first:25){ nodes{ id sku price selectedOptions{name value} inventoryItem{ id tracked } } } } }`;
const C = `mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){
  productVariantsBulkCreate(productId:$productId, variants:$variants){
    productVariants{ id sku price selectedOptions{name value} inventoryItem{ id } } userErrors{ field message } } }`;
const ACT = `mutation($iid:ID!,$loc:ID!){ inventoryActivate(inventoryItemId:$iid, locationId:$loc){ inventoryLevel{ id } userErrors{ message } } }`;
const SETQ = `mutation($input:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$input){ userErrors{ message code } } }`;
const MF = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{ id key value } userErrors{ field message } } }`;

const ledgerPath = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl');
const rbStream = fs.createWriteStream(path.join(outDir, 'rollback.jsonl'), { flags: 'a' });
const results = [];
let applied = 0, skipped = 0, errored = 0;

console.log(`TK-11124 add-per-yard-variant — token …${(TOKEN||'').slice(-4)} — mode: ${APPLY ? '⚠️  LIVE APPLY' : 'DRY-RUN'} — store ${SHOP}`);

const RUN = LIMIT ? TARGETS.slice(0, parseInt(LIMIT, 10)) : TARGETS;
for (const [dwsku, id] of RUN) {
  const d = await gql(Q, { id: gidP(id) });
  const p = d?.product;
  if (!p) { results.push({ dwsku, id, action: 'SKIP', reason: 'not-found' }); skipped++; continue; }
  const vs = p.variants.nodes;
  const sample = vs.find(v => /-sample$/i.test(v.sku||'') || parseFloat(v.price) <= 4.30);
  const sellable = vs.find(v => v !== sample && parseFloat(v.price) > 4.30);
  const yardSku = `${dwsku}-Yard`;

  if (p.status !== 'ACTIVE') { results.push({ dwsku, id, action: 'SKIP', reason: 'not-active:'+p.status }); skipped++; continue; }
  if (sellable || vs.some(v => (v.sku||'').toUpperCase() === yardSku.toUpperCase() || /sold per yard/i.test(v.selectedOptions?.map(o=>o.value).join('|')||''))) {
    results.push({ dwsku, id, action: 'SKIP', reason: 'already-has-sellable', existing: sellable?.sku }); skipped++; continue;
  }
  if (!(vs.length === 1 && sample)) { results.push({ dwsku, id, action: 'SKIP', reason: 'not-sample-only', nvar: vs.length }); skipped++; continue; }
  const soleOpt = (sample.selectedOptions?.[0]?.value || '').toLowerCase();
  if (soleOpt === 'default title') { results.push({ dwsku, id, action: 'SKIP', reason: 'default-title-would-consume-sample' }); skipped++; continue; }

  const preUnit = p.unit?.value ?? null;
  const plan = { dwsku, id: p.legacyResourceId, title: p.title, sample_sku: sample.sku, sample_price: sample.price, yard_sku: yardSku, price: PRICE, opt: `${OPT_NAME}:${OPT_VALUE}`, unit_from: preUnit, unit_to: UNIT_NEW };

  if (!APPLY) { results.push({ dwsku, id: p.legacyResourceId, action: 'WOULD-APPLY', ...plan }); continue; }

  const errs = [];
  // 1) create the sellable per-yard variant (DEFAULT strategy — appends 2nd Size value)
  const cr = await gql(C, { productId: gidP(id), variants: [{
    price: PRICE, optionValues: [{ optionName: OPT_NAME, name: OPT_VALUE }],
    inventoryPolicy: 'CONTINUE', inventoryItem: { sku: yardSku, tracked: true },
  }] });
  if (cr?.__err) { errored++; results.push({ dwsku, id, action: 'FAIL', reason: 'create:'+JSON.stringify(cr.__err).slice(0,160) }); continue; }
  const ue = cr.productVariantsBulkCreate?.userErrors || [];
  if (ue.length) { errored++; results.push({ dwsku, id, action: 'FAIL', reason: 'create-uerr:'+JSON.stringify(ue) }); continue; }
  const nv = (cr.productVariantsBulkCreate.productVariants || []).find(v => (v.sku||'').toUpperCase() === yardSku.toUpperCase()) || cr.productVariantsBulkCreate.productVariants[0];
  const iid = nv?.inventoryItem?.id;
  // record rollback BEFORE further steps so the created variant is always reversible
  rbStream.write(JSON.stringify({ dwsku, product_id: p.legacyResourceId, added_variant_id: nv.id, yard_sku: yardSku, unit_of_measure_old: preUnit, unit_of_measure_new: UNIT_NEW }) + '\n');

  // 2) inventoryActivate + set on_hand 2026 (FULL token)
  if (iid) {
    const a = await gql(ACT, { iid, loc: LOCATION });
    (a?.inventoryActivate?.userErrors || []).filter(e => !/already/i.test(e.message)).forEach(e => errs.push('activate:'+e.message));
    if (a?.__err) errs.push('activate:'+JSON.stringify(a.__err).slice(0,120));
    const sq = await gql(SETQ, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true,
      quantities: [{ inventoryItemId: iid, locationId: LOCATION, quantity: QTY }] } });
    (sq?.inventorySetQuantities?.userErrors || []).forEach(e => errs.push('setqty:'+e.message));
    if (sq?.__err) errs.push('setqty:'+JSON.stringify(sq.__err).slice(0,120));
  } else errs.push('no-inventoryItem-on-created-variant');

  // 3) align unit_of_measure metafield
  const mf = await gql(MF, { mf: [{ ownerId: gidP(id), namespace: 'global', key: 'unit_of_measure', type: 'single_line_text_field', value: UNIT_NEW }] });
  (mf?.metafieldsSet?.userErrors || []).forEach(e => errs.push('unit-mf:'+e.message));
  if (mf?.__err) errs.push('unit-mf:'+JSON.stringify(mf.__err).slice(0,120));

  // 4) self-verify: re-query, sample survived + sellable present + status still ACTIVE
  const re = await gql(Q, { id: gidP(id) });
  const rv = re?.product?.variants?.nodes || [];
  const sampleOk = rv.some(v => parseFloat(v.price) <= 4.30);
  const sellOk = rv.some(v => (v.sku||'').toUpperCase() === yardSku.toUpperCase() && parseFloat(v.price) > 4.30);
  const unitOk = (re?.product?.unit?.value || '') === UNIT_NEW;
  const statusOk = re?.product?.status === 'ACTIVE';
  const minPrice = Math.min(...rv.map(v => parseFloat(v.price)));

  if (!sampleOk) {
    errs.push('SAMPLE-LOST');
    results.push({ dwsku, id: p.legacyResourceId, action: 'FAIL-SAMPLE-LOST', added_variant_id: nv.id, errs });
    console.log(`⛔ ${dwsku} SAMPLE LOST after add — ABORT (rollback the added variant ${nv.id}). Evidence out/rollback.jsonl`);
    errored++; break; // never churn further
  }

  const action = (sellOk && statusOk && !errs.length) ? 'APPLIED' : 'APPLIED-WITH-WARN';
  results.push({ dwsku, id: p.legacyResourceId, action, added_variant_id: nv.id, yard_sku: yardSku, min_variant_price: minPrice, unit_ok: unitOk, status: re?.product?.status, errs });
  applied++;
  // ledger this reversible customer-facing write
  fs.appendFileSync(ledgerPath, JSON.stringify({
    ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-11124',
    action: `add per-yard sellable variant ${yardSku} @ $${PRICE} (Size:${OPT_VALUE}, CONTINUE, inv ${QTY}@Ventura) + unit_of_measure "${preUnit}"->"${UNIT_NEW}" on ${dwsku} product ${p.legacyResourceId} (${SHOP}) — makes sample-only ACTIVE product buyable`,
    blast_radius: 18,
    undo_cmd: `cd ~/Projects/dw-unbuyable-recovery-pilot/tk11124-dwve-velvet && node rollback.mjs --apply --i-am-steve   # productVariantsBulkDelete ${nv.id} on product ${p.legacyResourceId} + metafieldsSet global.unit_of_measure="${preUnit}"; map out/rollback.jsonl`,
    verify: `product ${p.legacyResourceId} variant ${yardSku} present @ $${PRICE}, sample $4.25 intact, status ACTIVE`,
  }) + '\n');
  await new Promise(r => setTimeout(r, 150));
}

rbStream.end();
fs.writeFileSync(path.join(outDir, `results-${APPLY?'apply':'dryrun'}.json`), JSON.stringify({ ticket: 'TK-11124', at: new Date().toISOString(), apply: APPLY, applied, skipped, errored, results }, null, 2));

console.log(`\n=== ${APPLY ? 'APPLIED' : 'DRY-RUN'} — ${applied} applied / ${skipped} skipped / ${errored} failed ===`);
for (const r of results) console.log(`  ${String(r.action).padEnd(20)} ${r.dwsku}${r.yard_sku?(' +'+r.yard_sku):''}${r.min_variant_price!==undefined?(' min$'+r.min_variant_price):''}${r.reason?('  reason='+r.reason):''}${r.errs&&r.errs.length?('  ERRS='+r.errs.join(';')):''}`);
console.log(`\nrollback map: out/rollback.jsonl  |  results: out/results-${APPLY?'apply':'dryrun'}.json`);
if (!APPLY) console.log('To execute: node apply.mjs --apply --i-am-steve');