← 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
A scripts/price-sheets/quadrille-fix-rollback.mjsA scripts/price-sheets/quadrille-fix-sellable.mjs
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 →