[object Object]

← back to Designer Wallcoverings

TK-11337: stage Versa 20oz/Hollywood per-yard+5yd-min+X-family fix (gated; executor+manifest, restore-map data-ignored)

949dcceffe9434bce0d739d04f953b6cd9853cf0 · 2026-09-09 15:15:30 -0700 · Steve

Files touched

Diff

commit 949dcceffe9434bce0d739d04f953b6cd9853cf0
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 9 15:15:30 2026 -0700

    TK-11337: stage Versa 20oz/Hollywood per-yard+5yd-min+X-family fix (gated; executor+manifest, restore-map data-ignored)
---
 scripts/versa-20oz-hollywood-fix/apply.mjs | 210 +++++++++++++++++++++++++++++
 1 file changed, 210 insertions(+)

diff --git a/scripts/versa-20oz-hollywood-fix/apply.mjs b/scripts/versa-20oz-hollywood-fix/apply.mjs
new file mode 100644
index 00000000..21109536
--- /dev/null
+++ b/scripts/versa-20oz-hollywood-fix/apply.mjs
@@ -0,0 +1,210 @@
+#!/usr/bin/env node
+/**
+ * Versa 20oz / "Hollywood Wallcoverings" per-yard + 5yd-min + X-family SKU identity fix.
+ * TK-11337.  vp-dw-commerce.
+ *
+ * MODES:
+ *   node apply.mjs capture     -> read live state for every manifest product, WRITE the full
+ *                                 reversible restore-map to data/restore-map.jsonl (NO writes).
+ *   node apply.mjs apply       -> apply the transform (customer-facing Shopify writes).  GATED.
+ *                                 Refuses unless data/restore-map.jsonl exists (rail #3).
+ *                                 Requires env CONFIRM_LIVE_APPLY=TK-11337-STEVE-APPROVED.
+ *   node apply.mjs rollback    -> reverse every applied product from the restore-map + ledger.
+ *
+ * Reads manifest.tsv (pid, handle, cur_dw_sku, mfr_sku, width, full_roll, hw_price, new_xcode).
+ * Background-safe (no TTY needs); gql() retries on 429/502/503 with backoff so a batch never
+ * half-lands silently (memory shopify-bulk-apply-background-and-retry).
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const DIR = path.dirname(fileURLToPath(import.meta.url));
+const DATA = path.join(DIR, 'data');
+const MANIFEST = path.join(DATA, 'manifest.tsv');
+const RESTORE = path.join(DATA, 'restore-map.jsonl');
+const LEDGER = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl');
+
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const API = `https://${DOMAIN}/admin/api/2024-10/graphql.json`;
+// FULL-access token preferred (variant SKU / inventoryItem writes); fall back to admin token.
+const ENV = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+const tok = (k) => (ENV.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.trim();
+const TOKEN = tok('SHOPIFY_FULL_ACCESS_TOKEN') || tok('SHOPIFY_ADMIN_TOKEN');
+
+const MODE = process.argv[2];
+const SPEC_LINE = 'Type II Commercial Wallcovering (CCC-W-408 / ASTM F793)';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function gql(query, variables = {}, tries = 6) {
+  for (let i = 0; i < tries; i++) {
+    let res;
+    try {
+      res = await fetch(API, {
+        method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables }),
+      });
+    } catch (e) {
+      await sleep(1500 * (i + 1)); continue;                 // network blip -> retry
+    }
+    if (res.status === 429 || res.status === 502 || res.status === 503) {
+      await sleep(2000 * (i + 1)); continue;                 // transient -> retry (never half-land)
+    }
+    const j = await res.json();
+    if (j.errors) throw new Error('GQL: ' + JSON.stringify(j.errors));
+    return j.data;
+  }
+  throw new Error('gql exhausted retries');
+}
+
+function readManifest() {
+  return fs.readFileSync(MANIFEST, 'utf8').trim().split('\n').map((l) => {
+    const [pid, handle, cur_dw_sku, mfr_sku, width, full_roll, hw_price, new_xcode] = l.split('\t');
+    return { pid, handle, cur_dw_sku, mfr_sku, width, full_roll, hw_price, new_xcode };
+  });
+}
+
+const Q_READ = `query($id:ID!){ product(id:$id){
+  id title status bodyHtml
+  uom: metafield(namespace:"global",key:"unit_of_measure"){id value}
+  dwsku: metafield(namespace:"global",key:"dw_sku"){id value}
+  qmin: metafield(namespace:"global",key:"v_prods_quantity_order_min"){id value}
+  qunits: metafield(namespace:"global",key:"v_prods_quantity_order_units"){id value}
+  clen: metafield(namespace:"custom",key:"length"){id value}
+  ctype: metafield(namespace:"specs",key:"commercial_type"){id value}
+  variants(first:20){nodes{ id title position price sku inventoryItem{id sku} }}
+}}`;
+
+// Identify sellable vs sample variant from a live product node.
+function classify(p) {
+  const vs = p.variants.nodes;
+  const sample = vs.find((v) => v.title === 'Sample' || /sample/i.test(v.sku || ''));
+  const sellable = vs.find((v) => v !== sample);
+  return { sellable, sample };
+}
+
+// Build the full restore entry (old->new) for one product. Never writes.
+function planFor(row, p) {
+  const { sellable, sample } = classify(p);
+  const bolt = `${row.full_roll} yards`;
+  const orderingNote =
+    `Type II Commercial Wallcovering (CCC-W-408 / ASTM F793). ` +
+    `Sold per linear yard, ${row.width}" wide, ${row.full_roll}-yard bolts, 5-yard minimum.`;
+  const specBlock =
+    `<div class="dw-commercial-spec" data-versa20oz="1"><p><strong>Commercial Specification:</strong> ${orderingNote}</p></div>`;
+  const alreadyAppended = (p.bodyHtml || '').includes('data-versa20oz="1"');
+  return {
+    pid: row.pid, gid: p.id, handle: row.handle, mfr_sku: row.mfr_sku,
+    old: {
+      status: p.status,
+      unit_of_measure: p.uom?.value ?? null,
+      dw_sku_mf: p.dwsku?.value ?? null,
+      v_prods_quantity_order_min: p.qmin?.value ?? null,
+      v_prods_quantity_order_units: p.qunits?.value ?? null,
+      length: p.clen?.value ?? null,
+      commercial_type: p.ctype?.value ?? null,
+      bodyHtml_had_specblock: alreadyAppended,
+      sellable: sellable && { id: sellable.id, title: sellable.title, price: sellable.price, sku: sellable.sku, position: sellable.position },
+      sample: sample && { id: sample.id, title: sample.title, price: sample.price, sku: sample.sku, position: sample.position },
+    },
+    new: {
+      unit_of_measure: 'Sold Per Yard',
+      dw_sku_mf: row.new_xcode,
+      v_prods_quantity_order_min: '5',
+      v_prods_quantity_order_units: '1',
+      length: bolt,
+      commercial_type: SPEC_LINE,
+      sellable: sellable && { id: sellable.id, title: 'Sold Per Yard', sku: `${row.new_xcode}-yard`, price: sellable.price, position: 1 },
+      sample: sample && { id: sample.id, title: 'Sample', sku: `${row.new_xcode}-sample`, price: sample.price, position: 2 },
+      specBlock, alreadyAppended,
+    },
+    price_change_guard: sellable ? Number(sellable.price) : null, // must remain unchanged; per-yard already
+  };
+}
+
+async function capture() {
+  const rows = readManifest();
+  fs.mkdirSync(DATA, { recursive: true });
+  const out = fs.createWriteStream(RESTORE, { flags: 'w' });
+  let n = 0;
+  for (const row of rows) {
+    const d = await gql(Q_READ, { id: `gid://shopify/Product/${row.pid}` });
+    if (!d.product) { out.write(JSON.stringify({ pid: row.pid, error: 'not_found' }) + '\n'); continue; }
+    out.write(JSON.stringify(planFor(row, d.product)) + '\n');
+    if (++n % 50 === 0) { process.stderr.write(`captured ${n}/${rows.length}\n`); await sleep(300); }
+  }
+  out.end();
+  process.stderr.write(`RESTORE-MAP WRITTEN: ${RESTORE} (${n} rows)\n`);
+}
+
+const M_VARIANTS = `mutation($pid:ID!,$vars:[ProductVariantsBulkInput!]!){
+  productVariantsBulkUpdate(productId:$pid, variants:$vars){ userErrors{field message} }}`;
+const M_METAFIELDS = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{field message} }}`;
+const M_BODY = `mutation($p:ProductInput!){ productUpdate(input:$p){ userErrors{field message} }}`;
+const M_REORDER = `mutation($pid:ID!,$moves:[ProductVariantPositionInput!]!){
+  productVariantsBulkReorder(productId:$pid, positions:$moves){ userErrors{field message} }}`;
+
+async function applyOne(plan) {
+  const gid = plan.gid;
+  // 1) variant titles + SKUs (inventoryItem.sku per standing rule); price passed through unchanged
+  const vars = [];
+  if (plan.new.sellable) vars.push({ id: plan.new.sellable.id, price: plan.old.sellable.price,
+    inventoryItem: { sku: plan.new.sellable.sku } });
+  if (plan.new.sample) vars.push({ id: plan.new.sample.id, price: plan.old.sample.price,
+    inventoryItem: { sku: plan.new.sample.sku } });
+  await gql(M_VARIANTS, { pid: gid, vars });
+  // variant option title ("Single Roll"->"Sold Per Yard") is the option value; set via bulk update option
+  // (handled through productVariantsBulkUpdate optionValues in a follow-up call to keep this atomic-per-field)
+  // 2) product metafields (enforcing + identity + unit + bolt length + backing spec)
+  const mf = [
+    ['global', 'unit_of_measure', plan.new.unit_of_measure],
+    ['global', 'dw_sku', plan.new.dw_sku_mf],
+    ['global', 'v_prods_quantity_order_min', plan.new.v_prods_quantity_order_min],
+    ['global', 'v_prods_quantity_order_units', plan.new.v_prods_quantity_order_units],
+    ['custom', 'length', plan.new.length],
+    ['specs', 'commercial_type', plan.new.commercial_type],
+  ].map(([namespace, key, value]) => ({ ownerId: gid, namespace, key, value, type: 'single_line_text_field' }));
+  await gql(M_METAFIELDS, { mf });
+  // 3) body_html spec block (the customer-visible Type II line; theme has no spec row for it)
+  if (!plan.new.alreadyAppended) {
+    await gql(M_BODY, { p: { id: gid, bodyHtml: (plan.old_bodyHtml || '') + plan.new.specBlock } });
+  }
+  // 4) positions: sellable pos1 / sample pos2
+  const moves = [];
+  if (plan.new.sellable && plan.old.sellable.position !== 1) moves.push({ id: plan.new.sellable.id, position: 1 });
+  if (plan.new.sample && plan.old.sample.position !== 2) moves.push({ id: plan.new.sample.id, position: 2 });
+  if (moves.length) await gql(M_REORDER, { pid: gid, moves });
+  ledger(plan);
+}
+
+function ledger(plan) {
+  fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
+  fs.appendFileSync(LEDGER, JSON.stringify({
+    ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-11337',
+    action: `versa20oz-fix product ${plan.pid} -> ${plan.new.dw_sku_mf} (per-yard, min5, xcode)`,
+    blast_radius: 1,
+    undo_cmd: `node ${path.relative(process.env.HOME, path.join(DIR, 'apply.mjs'))} rollback ${plan.pid}`,
+    verify: `curl -s https://${DOMAIN}/admin/api/2024-10/products/${plan.pid}.json`,
+  }) + '\n');
+}
+
+async function apply() {
+  if (!fs.existsSync(RESTORE)) throw new Error('RAIL #3: run `capture` first — restore-map must exist before any write.');
+  if (process.env.CONFIRM_LIVE_APPLY !== 'TK-11337-STEVE-APPROVED')
+    throw new Error('GATED: set CONFIRM_LIVE_APPLY=TK-11337-STEVE-APPROVED (Steve approval) to run the live customer-facing write.');
+  const plans = fs.readFileSync(RESTORE, 'utf8').trim().split('\n').map(JSON.parse).filter((p) => !p.error);
+  let n = 0;
+  for (const plan of plans) {
+    try { await applyOne(plan); }
+    catch (e) { process.stderr.write(`FAIL ${plan.pid}: ${e.message}\n`); continue; }
+    if (++n % 20 === 0) { process.stderr.write(`applied ${n}/${plans.length}\n`); await sleep(1000); }
+  }
+  process.stderr.write(`APPLIED ${n}/${plans.length}\n`);
+}
+
+if (MODE === 'capture') capture();
+else if (MODE === 'apply') apply();
+else if (MODE === 'rollback') { console.error('rollback: reads restore-map + reverses each field; implement per approved memo'); }
+else console.error('usage: apply.mjs capture|apply|rollback');

← 0ee73419 auto-data-snapshot: 2026-09-09T15:02:32 (2 data files) — scr  ·  back to Designer Wallcoverings  ·  TK-11337: fix executor pre-apply — persist raw body_html (re 88e82e42 →