[object Object]

← back to Designerwallcoverings

TK-11534: Quadrille 11 sellable-variant fixer + rollback (approved fix-forward, executed)

d9ac5f5d3c71b01a992de93d86c14c1928bfbfc9 · 2026-09-13 08:51:28 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUKRS8nTAFtE1hmVpgSHm2

Files touched

Diff

commit d9ac5f5d3c71b01a992de93d86c14c1928bfbfc9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 08:51:28 2026 -0700

    TK-11534: Quadrille 11 sellable-variant fixer + rollback (approved fix-forward, executed)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01VUKRS8nTAFtE1hmVpgSHm2
---
 scripts/price-sheets/quadrille-fix-rollback.mjs | 31 +++++++++
 scripts/price-sheets/quadrille-fix-sellable.mjs | 92 +++++++++++++++++++++++++
 2 files changed, 123 insertions(+)

diff --git a/scripts/price-sheets/quadrille-fix-rollback.mjs b/scripts/price-sheets/quadrille-fix-rollback.mjs
new file mode 100644
index 0000000..c289a21
--- /dev/null
+++ b/scripts/price-sheets/quadrille-fix-rollback.mjs
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+/**
+ * TK-11534 STREAM 1 rollback — restore the 11 Quadrille products to prior sample-only state.
+ * Reads data/price-sheets/quadrille-fix-created.json; for each: delete the created sellable
+ * variant (productVariantsBulkDelete) and rename the option back (Size -> optionRenamedFrom).
+ * USAGE: node quadrille-fix-rollback.mjs            # DRY-RUN
+ *        node quadrille-fix-rollback.mjs --apply    # LIVE undo
+ */
+import fs from 'node:fs';
+import { gql } from '../lib/shopify.mjs';
+const HERE = new URL('.', import.meta.url).pathname;
+const CREATED = HERE + '../../data/price-sheets/quadrille-fix-created.json';
+const APPLY = process.argv.includes('--apply');
+const created = JSON.parse(fs.readFileSync(CREATED, 'utf8'));
+const VDEL = `mutation($productId:ID!,$variantsIds:[ID!]!){ productVariantsBulkDelete(productId:$productId, variantsIds:$variantsIds){ userErrors{field message} } }`;
+const OPTRENAME = `mutation($productId:ID!,$option:OptionUpdateInput!){ productOptionUpdate(productId:$productId, option:$option){ userErrors{field message} } }`;
+const PGET = `query($id:ID!){ product(id:$id){ options{ id name } } }`;
+console.log(`quadrille-fix-rollback — ${APPLY ? 'LIVE UNDO' : 'DRY-RUN'} | ${created.length} records`);
+for (const c of created) {
+  console.log(`  ${c.dw_sku}: delete ${c.sellVarId}${c.optionRenamedFrom ? ` + rename Size->${c.optionRenamedFrom}` : ''}`);
+  if (!APPLY) continue;
+  const d = await gql(VDEL, { productId: c.gid, variantsIds: [c.sellVarId] });
+  console.log('    del:', JSON.stringify(d?.productVariantsBulkDelete?.userErrors || d?.__err || 'ok'));
+  if (c.optionRenamedFrom) {
+    const p = await gql(PGET, { id: c.gid });
+    const opt = p.product.options[0];
+    const r = await gql(OPTRENAME, { productId: c.gid, option: { id: opt.id, name: c.optionRenamedFrom } });
+    console.log('    rename:', JSON.stringify(r?.productOptionUpdate?.userErrors || r?.__err || 'ok'));
+  }
+}
+console.log('done');
diff --git a/scripts/price-sheets/quadrille-fix-sellable.mjs b/scripts/price-sheets/quadrille-fix-sellable.mjs
new file mode 100644
index 0000000..8f2f213
--- /dev/null
+++ b/scripts/price-sheets/quadrille-fix-sellable.mjs
@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+/**
+ * TK-11534 STREAM 1 — Quadrille 11 sample-only ACTIVE products: add the real SELLABLE
+ * per-yard variant (line convention "Sold Per Yard -  {W}In Wide", option "Size") at the
+ * catalog our_price, mirroring the 432 healthy Quadrille products. mfr_sku is ALREADY set
+ * on Shopify (custom.manufacturer_sku) so nothing to attach.
+ *
+ * Per product: (1) VERIFY-BEFORE-ACTING — still ACTIVE, still single Sample-only variant;
+ * SKIP if a sellable variant already exists (idempotent) or status changed. (2) Rename the
+ * product's implicit option "Title" -> "Size". (3) Add the sellable variant (untracked,
+ * matching the template's tracked:false) at our_price, sku = base DW_SKU.
+ *
+ * Reversible: created rows -> data/price-sheets/quadrille-fix-created.json. Undo per SKU =
+ *   productVariantsBulkDelete(sellVarId) + productOptionUpdate rename Size->Title.
+ *
+ * Uses lib/shopify.mjs (prefers SHOPIFY_FULL_ACCESS_TOKEN). No inventory write needed
+ * (sellable is untracked), so write_products scope suffices.
+ *
+ * USAGE: node quadrille-fix-sellable.mjs [--limit=1]            # DRY-RUN
+ *        node quadrille-fix-sellable.mjs --limit=1 --apply      # LIVE (1 product)
+ *        node quadrille-fix-sellable.mjs --apply                # LIVE (all remaining)
+ */
+import fs from 'node:fs';
+import { gql } from '../lib/shopify.mjs';
+
+const HERE = new URL('.', import.meta.url).pathname;
+const TARGETS = process.env.QF_TARGETS
+  || '/private/tmp/claude-501/-Users-macstudio3-Projects-designerwallcoverings/f5511869-92b7-4c17-a6d8-32c84b0a7711/scratchpad/quadrille-targets.psv';
+const CREATED = HERE + '../../data/price-sheets/quadrille-fix-created.json';
+const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v === undefined ? true : v]; }));
+const APPLY = args.apply === true;
+const LIMIT = args.limit ? parseInt(args.limit, 10) : Infinity;
+
+let targets = fs.readFileSync(TARGETS, 'utf8').trim().split('\n').map(l => {
+  const [dw_sku, pid_num, mfr, our_price, w_in] = l.split('|');
+  return { dw_sku, gid: `gid://shopify/Product/${pid_num}`, mfr, price: (+our_price).toFixed(2), width: parseInt(w_in, 10) };
+});
+
+// idempotent: skip already-created
+let created = []; try { created = JSON.parse(fs.readFileSync(CREATED, 'utf8')); } catch {}
+const doneSkus = new Set(created.map(c => c.dw_sku));
+const alreadyDone = targets.filter(t => doneSkus.has(t.dw_sku)).length;
+targets = targets.filter(t => !doneSkus.has(t.dw_sku));
+if (alreadyDone) console.log(`(skipping ${alreadyDone} already in created-log)`);
+if (Number.isFinite(LIMIT)) targets = targets.slice(0, LIMIT);
+
+console.log(`quadrille-fix-sellable — mode: ${APPLY ? '⚠️  LIVE APPLY' : 'DRY-RUN'} | targets: ${targets.length}`);
+
+const PGET = `query($id:ID!){ product(id:$id){ id title status
+  options{ id name values }
+  variants(first:20){ nodes{ id title sku price selectedOptions{name value} } } } }`;
+const OPTRENAME = `mutation($productId:ID!,$option:OptionUpdateInput!){ productOptionUpdate(productId:$productId, option:$option){ userErrors{field message} product{ options{ id name } } } }`;
+const VCREATE = `mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){ productVariantsBulkCreate(productId:$productId, variants:$variants){ productVariants{ id sku title price selectedOptions{name value} } userErrors{field message} } }`;
+
+let ok = 0, skip = 0, err = 0;
+const results = [];
+for (const t of targets) {
+  const d = await gql(PGET, { id: t.gid });
+  const p = d?.product;
+  if (!p) { err++; results.push({ dw_sku: t.dw_sku, action: 'ERR', reason: 'product not found' }); console.log(`  ERR ${t.dw_sku}: product not found`); continue; }
+  // VERIFY-BEFORE-ACTING
+  if (p.status !== 'ACTIVE') { skip++; results.push({ dw_sku: t.dw_sku, action: 'SKIP', reason: `status=${p.status}` }); console.log(`  SKIP ${t.dw_sku}: status=${p.status}`); continue; }
+  const vs = p.variants.nodes;
+  const nonSample = vs.filter(v => !/sample/i.test(v.title) && !/sample/i.test(v.sku || ''));
+  if (nonSample.length > 0) { skip++; results.push({ dw_sku: t.dw_sku, action: 'SKIP', reason: `already has sellable variant: ${nonSample.map(v => v.sku).join(',')}` }); console.log(`  SKIP ${t.dw_sku}: already has sellable variant since memo (${nonSample.map(v => v.sku).join(',')})`); continue; }
+  const opt = p.options[0];
+  const sellTitle = `Sold Per Yard -  ${t.width}In Wide`; // NOTE: double space matches template
+  console.log(`  FIX ${t.dw_sku}: option '${opt.name}'->'Size', add "${sellTitle}" sku=${t.dw_sku} @ $${t.price}`);
+  if (!APPLY) { results.push({ dw_sku: t.dw_sku, action: 'DRY', sellTitle, price: t.price }); continue; }
+  // 1) rename option -> Size (only if needed)
+  let renamedFrom = null;
+  if (opt.name !== 'Size') {
+    const r = await gql(OPTRENAME, { productId: t.gid, option: { id: opt.id, name: 'Size' } });
+    const ue = r?.productOptionUpdate?.userErrors || [];
+    if (r?.__err || ue.length) { err++; results.push({ dw_sku: t.dw_sku, action: 'ERR', reason: 'optRename ' + JSON.stringify(r?.__err || ue) }); console.log(`  ERR ${t.dw_sku} optRename:`, JSON.stringify(r?.__err || ue).slice(0, 160)); continue; }
+    renamedFrom = opt.name;
+  }
+  // 2) add sellable variant (untracked, matching template)
+  const c = await gql(VCREATE, { productId: t.gid, variants: [{ price: t.price, inventoryItem: { sku: t.dw_sku, tracked: false }, optionValues: [{ optionName: 'Size', name: sellTitle }] }] });
+  const ue2 = c?.productVariantsBulkCreate?.userErrors || [];
+  if (c?.__err || ue2.length) { err++; results.push({ dw_sku: t.dw_sku, action: 'ERR', reason: 'vCreate ' + JSON.stringify(c?.__err || ue2), renamedFrom }); console.log(`  ERR ${t.dw_sku} vCreate:`, JSON.stringify(c?.__err || ue2).slice(0, 160)); continue; }
+  const nv = c.productVariantsBulkCreate.productVariants[0];
+  ok++;
+  const rec = { dw_sku: t.dw_sku, gid: t.gid, sellVarId: nv.id, sellSku: nv.sku, price: nv.price, sellTitle, optionRenamedFrom: renamedFrom, ts: new Date().toISOString() };
+  created.push(rec); results.push({ dw_sku: t.dw_sku, action: 'CREATED', ...rec });
+  fs.mkdirSync(HERE + '../../data/price-sheets', { recursive: true });
+  fs.writeFileSync(CREATED, JSON.stringify(created, null, 2));
+  console.log(`    created ${nv.sku} @ $${nv.price} (${nv.id})`);
+}
+console.log(`\nDONE — created ${ok}, skipped ${skip}, errors ${err}`);
+if (APPLY) console.log(`reversible log: ${CREATED}`);
+fs.writeFileSync('/private/tmp/claude-501/-Users-macstudio3-Projects-designerwallcoverings/f5511869-92b7-4c17-a6d8-32c84b0a7711/scratchpad/quadrille-fix-results.json', JSON.stringify(results, null, 2));

← ad0feaf auto-data-snapshot: 2026-09-13T08:07:44 (5 data files) — scr  ·  back to Designerwallcoverings  ·  chore: version-up v0.1.16 (session close, TK-11534) 643efb0 →