[object Object]

← back to Designer Wallcoverings

Add dry-run-first fix scripts for active-last-week 2-variant review (41 missing-sample + 434 only-sample)

fb9148137dc391c8a8adbf31bf389ade9192f4f9 · 2026-06-26 23:35:30 -0700 · Steve

Files touched

Diff

commit fb9148137dc391c8a8adbf31bf389ade9192f4f9
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 26 23:35:30 2026 -0700

    Add dry-run-first fix scripts for active-last-week 2-variant review (41 missing-sample + 434 only-sample)
---
 shopify/scripts/fix-active-lastweek-only-sample.js |  86 +++++++++++++++++
 shopify/scripts/fix-active-lastweek-samples.js     | 102 +++++++++++++++++++++
 2 files changed, 188 insertions(+)

diff --git a/shopify/scripts/fix-active-lastweek-only-sample.js b/shopify/scripts/fix-active-lastweek-only-sample.js
new file mode 100644
index 00000000..38b118a0
--- /dev/null
+++ b/shopify/scripts/fix-active-lastweek-only-sample.js
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+/**
+ * fix-active-lastweek-only-sample.js
+ * Remediation for the "only-sample, no sellable roll" cohort from the 2026-06-26
+ * active-last-week 2-variant review (434 products across Phillipe Romano,
+ * Quadrille, Alan Campbell, Suncloth, Plains, Charles Burger, Home Couture,
+ * Cloth and Paper, Fentucci). These went ACTIVE+published with ONLY a Sample
+ * variant — a customer sees the product page but cannot buy the roll/yardage.
+ *
+ * Per Steve's HARD activation gate, a SKU must NOT be active without a sellable
+ * product variant priced off CONFIRMED vendor cost. We must NOT fabricate a roll
+ * price. The gate-compliant, reversible default is therefore: revert to DRAFT
+ * (status=draft) until purchasing confirms cost and the roll variant is built,
+ * after which cadence re-activates it normally.
+ *
+ * The gap snapshot already classifies 91 of these as fix_type='build-roll' +
+ * priceable=FALSE (no catalog cost). The remaining 343 weren't in the snapshot
+ * (too recent) and need a fresh cost check before any build-roll path.
+ *
+ * SAFE BY DEFAULT — dry-run unless --apply. status flip is fully reversible.
+ *   node shopify/scripts/fix-active-lastweek-only-sample.js            # dry-run
+ *   node shopify/scripts/fix-active-lastweek-only-sample.js --apply    # GATED: set DRAFT
+ */
+const fs = require('fs');
+const path = require('path');
+
+const APPLY = process.argv.includes('--apply');
+const TSV = process.env.TSV || '/tmp/dw_only_sample_no_product.tsv';
+const LEDGER = path.join(__dirname, 'data', 'fix-active-lastweek-only-sample.ledger.jsonl');
+
+const env = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
+const ev = k => (env.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1];
+const STORE = ev('SHOPIFY_STORE_DOMAIN') || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = ev('SHOPIFY_ADMIN_TOKEN');
+const REST = `https://${STORE}/admin/api/2024-10`;
+
+async function rest(method, p, body) {
+  const r = await fetch(REST + p, {
+    method,
+    headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN },
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  return { status: r.status, json: await r.json().catch(() => ({})) };
+}
+
+function loadTargets() {
+  return fs.readFileSync(TSV, 'utf8').trim().split('\n').slice(1).map(l => {
+    const [pid, vendor, , , title] = l.split('\t');
+    return { pid, vendor, title: (title || '').replace(/^"|"$/g, '') };
+  });
+}
+
+(async () => {
+  if (!TOKEN) { console.error('No SHOPIFY_ADMIN_TOKEN in .env'); process.exit(1); }
+  fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
+  const done = new Set(
+    fs.existsSync(LEDGER)
+      ? fs.readFileSync(LEDGER, 'utf8').trim().split('\n').filter(Boolean)
+          .map(l => JSON.parse(l)).filter(x => x.result === 'drafted').map(x => x.pid)
+      : []
+  );
+  const targets = loadTargets().filter(t => !done.has(t.pid));
+  console.log(`fix-active-lastweek-only-sample ${APPLY ? '[APPLY → DRAFT]' : '[DRY-RUN]'}  targets=${targets.length}`);
+  const byV = {};
+  const stats = { drafted: 0, err: 0 };
+  for (let i = 0; i < targets.length; i++) {
+    const t = targets[i];
+    byV[t.vendor] = (byV[t.vendor] || 0) + 1;
+    if (!APPLY) { continue; }
+    const r = await rest('PUT', `/products/${t.pid}.json`, { product: { id: Number(t.pid), status: 'draft' } });
+    if (r.status >= 200 && r.status < 300) {
+      stats.drafted++;
+      fs.appendFileSync(LEDGER, JSON.stringify({ pid: t.pid, vendor: t.vendor, result: 'drafted', ts: new Date().toISOString() }) + '\n');
+      if (stats.drafted % 25 === 0) console.log(`  [${i + 1}/${targets.length}] drafted ${stats.drafted}`);
+    } else {
+      stats.err++;
+      fs.appendFileSync(LEDGER, JSON.stringify({ pid: t.pid, result: 'error', status: r.status, ts: new Date().toISOString() }) + '\n');
+    }
+  }
+  if (!APPLY) {
+    console.log('would set status=DRAFT for these vendors:', JSON.stringify(byV));
+    console.log('(re-activation happens via cadence once roll variant + confirmed cost land)');
+  } else {
+    console.log(`done: drafted=${stats.drafted} err=${stats.err}`);
+  }
+})();
diff --git a/shopify/scripts/fix-active-lastweek-samples.js b/shopify/scripts/fix-active-lastweek-samples.js
new file mode 100644
index 00000000..0207ea51
--- /dev/null
+++ b/shopify/scripts/fix-active-lastweek-samples.js
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+/**
+ * fix-active-lastweek-samples.js
+ * ADDITIVE sample-variant pass for the "missing-sample" cohort surfaced by the
+ * 2026-06-26 active-last-week 2-variant review (41 products: 39 Zoffany + 2 PR).
+ * Each target already has a real, priced roll variant on the live store and is
+ * MISSING only its Sample variant — so this is purely additive (never edits the
+ * existing roll variant, never changes price/status).
+ *
+ * Convention mirrors active-gate-add-samples.js (the proven mechanic):
+ *   POST /products/<id>/variants.json {variant:{option1:'Sample',price:<samplePrice>,sku:<base>-Sample,...}}
+ *
+ * Sample price defaults to $4.25 (DW standard memo-sample). Vendor overrides via
+ * SAMPLE_PRICE_BY_VENDOR below — confirm with purchasing before --apply if a line
+ * prices its memo differently.
+ *
+ * SAFE BY DEFAULT — dry-run unless --apply is passed. Resumable via ledger.
+ *   node shopify/scripts/fix-active-lastweek-samples.js              # dry-run
+ *   node shopify/scripts/fix-active-lastweek-samples.js --apply      # GATED: execute
+ */
+const fs = require('fs');
+const path = require('path');
+
+const APPLY = process.argv.includes('--apply');
+const TSV = process.env.TSV || '/tmp/dw_missing_sample.tsv';
+const LEDGER = path.join(__dirname, 'data', 'fix-active-lastweek-samples.ledger.jsonl');
+
+// env-first token (never hardcode)
+const env = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
+const ev = k => (env.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1];
+const STORE = ev('SHOPIFY_STORE_DOMAIN') || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = ev('SHOPIFY_ADMIN_TOKEN');
+const REST = `https://${STORE}/admin/api/2024-10`;
+
+const DEFAULT_SAMPLE_PRICE = '4.25';
+const SAMPLE_PRICE_BY_VENDOR = {
+  // 'Zoffany': '4.25',   // confirm w/ purchasing if Style Library memo differs
+};
+
+async function rest(method, p, body) {
+  const r = await fetch(REST + p, {
+    method,
+    headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN },
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  return { status: r.status, json: await r.json().catch(() => ({})) };
+}
+
+function loadTargets() {
+  const lines = fs.readFileSync(TSV, 'utf8').trim().split('\n').slice(1);
+  return lines.map(l => {
+    const [pid, vendor, , , title] = l.split('\t');
+    return { pid, vendor, title: (title || '').replace(/^"|"$/g, '') };
+  });
+}
+
+(async () => {
+  if (!TOKEN) { console.error('No SHOPIFY_ADMIN_TOKEN in .env'); process.exit(1); }
+  fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
+  const done = new Set(
+    fs.existsSync(LEDGER)
+      ? fs.readFileSync(LEDGER, 'utf8').trim().split('\n').filter(Boolean)
+          .map(l => JSON.parse(l)).filter(x => x.result === 'created' || x.result === 'already').map(x => x.pid)
+      : []
+  );
+  const targets = loadTargets().filter(t => !done.has(t.pid));
+  console.log(`fix-active-lastweek-samples ${APPLY ? '[APPLY]' : '[DRY-RUN]'}  targets=${targets.length}`);
+  const stats = { created: 0, already: 0, err: 0 };
+
+  for (let i = 0; i < targets.length; i++) {
+    const t = targets[i];
+    // pull live variants to derive base sku + guard against an existing sample
+    const { json } = await rest('GET', `/products/${t.pid}/variants.json`);
+    const vs = json.variants || [];
+    if (!vs.length) { console.log(`  ! ${t.pid} no variants (skip)`); stats.err++; continue; }
+    if (vs.some(v => /sample/i.test(v.title || '') || /-sample$/i.test(v.sku || ''))) {
+      stats.already++;
+      fs.appendFileSync(LEDGER, JSON.stringify({ pid: t.pid, result: 'already', ts: new Date().toISOString() }) + '\n');
+      continue;
+    }
+    const base = (vs[0].sku || '').replace(/-sample$/i, '');
+    const sampleSku = base ? `${base}-Sample` : `SAMPLE-${t.pid}`;
+    const price = SAMPLE_PRICE_BY_VENDOR[t.vendor] || DEFAULT_SAMPLE_PRICE;
+    if (!APPLY) {
+      console.log(`  • [${t.vendor}] ${t.title.slice(0, 48)} → +Sample ${sampleSku} @ $${price}`);
+      stats.created++; continue;
+    }
+    const r = await rest('POST', `/products/${t.pid}/variants.json`, {
+      variant: { option1: 'Sample', price, sku: sampleSku, weight: 0, weight_unit: 'lb', inventory_management: 'shopify', requires_shipping: true },
+    });
+    if (r.status >= 200 && r.status < 300) {
+      stats.created++;
+      fs.appendFileSync(LEDGER, JSON.stringify({ pid: t.pid, sampleSku, price, result: 'created', ts: new Date().toISOString() }) + '\n');
+      if (stats.created % 10 === 0) console.log(`  [${i + 1}/${targets.length}] ✓ ${stats.created} (last ${sampleSku})`);
+    } else {
+      stats.err++;
+      fs.appendFileSync(LEDGER, JSON.stringify({ pid: t.pid, result: 'error', status: r.status, body: r.json, ts: new Date().toISOString() }) + '\n');
+      console.log(`  ✗ ${t.pid} HTTP ${r.status}`);
+    }
+  }
+  console.log(`done: created=${stats.created} already=${stats.already} err=${stats.err}`);
+})();

← 2afc375b auto-save: 2026-06-26T17:04:47 (3 files) — pending-approval/  ·  back to Designer Wallcoverings  ·  Add contact-for-price PDP section + additive tagging script 0b2efa54 →