[object Object]

← back to Designer Wallcoverings

TK-17: add reversible Batch A (quotes tag) + Batch B (Hollywood roll variant) execution scripts (dry-run default)

54898819f8376b8ec53d489b78c5d61575537a0c · 2026-07-27 16:21:11 -0700 · Steve

Files touched

Diff

commit 54898819f8376b8ec53d489b78c5d61575537a0c
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 27 16:21:11 2026 -0700

    TK-17: add reversible Batch A (quotes tag) + Batch B (Hollywood roll variant) execution scripts (dry-run default)
---
 shopify/scripts/tk17-batchA-quotes-tag.js          |  94 ++++++++++
 .../scripts/tk17-batchB-hollywood-roll-variants.js | 195 +++++++++++++++++++++
 2 files changed, 289 insertions(+)

diff --git a/shopify/scripts/tk17-batchA-quotes-tag.js b/shopify/scripts/tk17-batchA-quotes-tag.js
new file mode 100644
index 00000000..8c3270f3
--- /dev/null
+++ b/shopify/scripts/tk17-batchA-quotes-tag.js
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+/**
+ * tk17-batchA-quotes-tag.js  (2026-07-27)  — owner: vp-dw-commerce
+ *
+ * TK-17 Batch A + A′ — append the `quotes` tag to the 9,684 quote-only backlog
+ * products across 11 approved vendor lines. This cures the dead-end buy-box UX
+ * and exits them from the backlog honestly, at ZERO pricing risk.
+ *
+ * ADAPTED from the canonical tagsAdd pattern (thibaut-mfr-tag.js):
+ *   - reads the TK-17 worklist  (GID \t vendor) — NOT a live vendor query
+ *   - tag applied is the literal `quotes` (not the mfr sku)
+ *   - tagsAdd is APPEND-ONLY + IDEMPOTENT — it does NOT clobber existing tags,
+ *     and re-running is a no-op. Never touches title / price / variant / status.
+ *
+ *   node tk17-batchA-quotes-tag.js --dry-run                 # counts only, no writes
+ *   node tk17-batchA-quotes-tag.js --apply --limit 20        # canary (Steve-gated)
+ *   node tk17-batchA-quotes-tag.js --apply --report data/tk17/batchA-run.json
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const DRY = args.includes('--dry-run') || !APPLY;          // default: dry-run
+const LIMIT = (() => { const i = args.indexOf('--limit'); return i >= 0 ? parseInt(args[i + 1], 10) : Infinity; })();
+const REPORT = (() => { const i = args.indexOf('--report'); return i >= 0 ? args[i + 1] : null; })();
+const TSV = (() => { const i = args.indexOf('--worklist'); return i >= 0 ? args[i + 1]
+  : os.homedir() + '/Projects/consulting-designerwallcoverings-com/data/work-orders/execution/batchA-quotes-tag-worklist.tsv'; })();
+
+const TAG = 'quotes';
+const APPROVED = new Set(['phillip jeffries','koroseal','china seas','maya romanoff','newmor wallcoverings','quadrille','alan campbell','architectural fabrics','wolf gordon','home couture','donghia']);
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+if (!TOKEN) { console.error('Missing SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables = {}) {
+  for (let a = 0; a < 6; a++) {
+    let json;
+    try {
+      const res = await fetch(ENDPOINT, { method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables }) });
+      json = await res.json();
+    } catch (netErr) { if (a === 5) throw netErr; await sleep(1500 * (a + 1)); continue; }
+    if (json.errors) {
+      const throttled = JSON.stringify(json.errors).includes('Throttled');
+      if (a === 5) throw new Error(JSON.stringify(json.errors));
+      await sleep((throttled ? 2500 : 800) * (a + 1)); continue;
+    }
+    const remaining = json.extensions?.cost?.throttleStatus?.currentlyAvailable;
+    if (remaining !== undefined && remaining < 300) await sleep(600);
+    return json;
+  }
+}
+const TAGS_ADD = `mutation($id:ID!, $tags:[String!]!){ tagsAdd(id:$id, tags:$tags){ userErrors{ field message } } }`;
+
+function loadWorklist() {
+  const lines = fs.readFileSync(TSV, 'utf8').trim().split('\n');
+  return lines.map(l => { const [gid, vendor] = l.split('\t'); return { gid: (gid || '').trim(), vendor: (vendor || '').trim() }; })
+              .filter(r => r.gid);
+}
+
+(async () => {
+  let rows = loadWorklist();
+  // vendor-guard: every row must be one of the 11 approved lines
+  const bad = rows.filter(r => !APPROVED.has(r.vendor.toLowerCase()));
+  if (bad.length) { console.error(`REFUSING: ${bad.length} rows outside the 11 approved vendors (e.g. ${bad.slice(0,3).map(b=>b.vendor).join(', ')})`); process.exit(3); }
+  if (LIMIT !== Infinity) rows = rows.slice(0, LIMIT);
+
+  const byV = {}; rows.forEach(r => byV[r.vendor] = (byV[r.vendor]||0)+1);
+  console.log(`tk17-batchA (quotes tag) — ${rows.length} products — ${APPLY ? '🔴 LIVE' : 'DRY-RUN'}\n  worklist: ${TSV}\n  tag: "${TAG}" (append-only, idempotent)\n  per-vendor:`, JSON.stringify(byV));
+
+  const report = { ticket: 'TK-17', batch: 'A', started: new Date().toISOString(), apply: APPLY, worklist: TSV, tag: TAG, tagged: 0, errors: [], byVendor: byV };
+
+  if (!APPLY) { console.log(`\n(dry-run) would append "${TAG}" to ${rows.length} products. No writes.`); return; }
+
+  let tagged = 0;
+  for (const r of rows) {
+    const { data } = await gql(TAGS_ADD, { id: r.gid, tags: [TAG] });
+    const errs = data.tagsAdd?.userErrors || [];
+    if (errs.length) report.errors.push({ gid: r.gid, errs }); else tagged++;
+    if (tagged % 100 === 0 || tagged === rows.length) process.stdout.write(`\r  tagged ${tagged}/${rows.length}  errors=${report.errors.length}`);
+    await sleep(120);
+  }
+  process.stdout.write('\n');
+  report.tagged = tagged; report.finished = new Date().toISOString();
+  console.log(`  done  tagged=${tagged}  errors=${report.errors.length}`);
+  if (REPORT) { fs.mkdirSync(path.dirname(REPORT), { recursive: true }); fs.writeFileSync(REPORT, JSON.stringify(report, null, 2)); console.log(`report -> ${REPORT}`); }
+})().catch(e => { console.error('\nFATAL', e); process.exit(1); });
diff --git a/shopify/scripts/tk17-batchB-hollywood-roll-variants.js b/shopify/scripts/tk17-batchB-hollywood-roll-variants.js
new file mode 100644
index 00000000..5b773901
--- /dev/null
+++ b/shopify/scripts/tk17-batchB-hollywood-roll-variants.js
@@ -0,0 +1,195 @@
+#!/usr/bin/env node
+/**
+ * tk17-batchB-hollywood-roll-variants.js  (2026-07-27)  — owner: vp-dw-commerce
+ *
+ * TK-17 Batch B — create the sellable ROLL variant on Hollywood Wallcoverings
+ * (private-label of Momentum/Versa — those names NEVER surface) backlog products.
+ *
+ * ADAPTED from the canonical bucketA-add-roll-variants.js for TK-17:
+ *   - reads the TK-17 worklist  (GID \t mfr_sku \t vendor \t hw_price)
+ *   - shopify_id is a FULL GID (gid://shopify/Product/…) — used verbatim, NOT re-wrapped
+ *   - PRICING: Hollywood prices at hw_price DIRECTLY (it is already the sell figure).
+ *     Feeding hw_price through cost/0.65/0.85 would misprice ~1.8x. (Cody-verified gate.)
+ *   - PRICE-CEILING GUARD: any hw_price outside $10–$100 is FLAGGED + skipped.
+ *   - inventoryItem.cost is intentionally left UNSET for Hollywood (hw_price is a sell
+ *     price, not a cost; we do not invent a cost). Sample variant is left untouched.
+ *
+ * ORDER OF OPS (PostgreSQL BEFORE Shopify), idempotent, NEVER flips status:
+ *   1. read hw_price from the worklist (already reconciled against the mirror)
+ *   2. WRITE roll price into dw_unified.shopify_products.retail_price (source of truth first)
+ *   3. create the roll variant via productVariantsBulkCreate, reorder roll-first, metafields
+ *
+ * GUARDS (same as canonical):
+ *   - variant SKU = existing Sample sku minus `-Sample`, else worklist mfr_sku. Never "Unknown".
+ *   - skip any product that already has a non-Sample variant (idempotent).
+ *   - NEVER changes product status; never touches title / customer-facing vendor / display_variant.
+ *
+ *   node tk17-batchB-hollywood-roll-variants.js --dry-run                 # DRY-RUN (writes nothing)
+ *   node tk17-batchB-hollywood-roll-variants.js --apply --limit 20        # canary (Steve-gated)
+ *   node tk17-batchB-hollywood-roll-variants.js --apply --report data/tk17/batchB-run.json
+ */
+const https = require('https');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const DRY = args.includes('--dry-run') || !APPLY;          // default: dry-run
+const LIMIT = (() => { const i = args.indexOf('--limit'); return i >= 0 ? parseInt(args[i + 1], 10) : Infinity; })();
+const REPORT = (() => { const i = args.indexOf('--report'); return i >= 0 ? args[i + 1] : null; })();
+const TSV = (() => { const i = args.indexOf('--worklist'); return i >= 0 ? args[i + 1]
+  : os.homedir() + '/Projects/consulting-designerwallcoverings-com/data/work-orders/execution/batchB-hollywood-full.tsv'; })();
+
+const TODAY = new Date().toISOString().slice(0, 10);
+const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const round2 = n => Math.round((n + Number.EPSILON) * 100) / 100;
+
+// ---- pricing (Cody gate) ---------------------------------------------------
+const PRICE_MIN = 10, PRICE_MAX = 100;
+function priceForHollywood(hwPrice) {
+  return { rule: 'hw_price', price: round2(hwPrice) };   // sell figure directly
+}
+
+// ---- dw_unified write (PG-first). shopify_id is a FULL GID here. ------------
+function pgWriteRollPrice(rows) {
+  const values = rows.map(r => `('${r.gid.replace(/'/g, "''")}', ${r.price})`).join(',\n');
+  const sql = `
+BEGIN;
+CREATE TEMP TABLE _tk17B_price(gid text, roll_price numeric);
+INSERT INTO _tk17B_price(gid, roll_price) VALUES
+${values};
+UPDATE shopify_products sp
+   SET retail_price = b.roll_price
+  FROM _tk17B_price b
+ WHERE sp.shopify_id = b.gid;
+SELECT count(*) AS updated FROM _tk17B_price b JOIN shopify_products sp ON sp.shopify_id=b.gid;
+COMMIT;
+`;
+  const out = execFileSync('ssh', ['my-server',
+    `sudo -u postgres psql -d dw_unified -v ON_ERROR_STOP=1 -t -A`],
+    { input: sql, encoding: 'utf8', maxBuffer: 1 << 24 });
+  return out.trim();
+}
+
+// ---- Shopify GraphQL -------------------------------------------------------
+function gql(query, variables = {}) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({ query, variables });
+    const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) } },
+      r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(d); } }); });
+    req.on('error', reject); req.write(body); req.end();
+  });
+}
+async function gqlR(q, v, tries = 4) {
+  for (let i = 0; i < tries; i++) {
+    const r = await gql(q, v);
+    if (r && !r.errors) return r;
+    const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
+    if (!throttled) return r;
+    await sleep(1500 * (i + 1));
+  }
+  return gql(q, v);
+}
+const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
+  options{name values}
+  variants(first:30){nodes{id title sku selectedOptions{name value} inventoryItem{id}}}}}`;
+const M_VAR_CREATE = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
+  productVariantsBulkCreate(productId:$pid,variants:$variants){
+    productVariants{id sku} userErrors{field message}}}`;
+const M_REORDER = `mutation($pid:ID!,$positions:[ProductVariantPositionInput!]!){
+  productVariantsBulkReorder(productId:$pid,positions:$positions){userErrors{field message}}}`;
+const M_MF = `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{field message}}}`;
+
+// ---- worklist  (GID \t mfr_sku \t vendor \t hw_price) -----------------------
+function loadWorklist() {
+  const lines = fs.readFileSync(TSV, 'utf8').trim().split('\n');
+  return lines.map(l => {
+    const [gid, mfr_sku, vendor, hw_price] = l.split('\t');
+    return { gid: (gid || '').trim(), mfr_sku: (mfr_sku || '').trim(),
+             vendor: (vendor || '').trim(), hw_price: parseFloat(hw_price) };
+  });
+}
+
+(async () => {
+  let rows = loadWorklist();
+  if (LIMIT !== Infinity) rows = rows.slice(0, LIMIT);
+  console.log(`tk17-batchB (Hollywood roll variants) — ${rows.length} products — ${APPLY ? '🔴 LIVE' : 'DRY-RUN'}\n  worklist: ${TSV}\n`);
+
+  const report = { ticket: 'TK-17', batch: 'B', started: new Date().toISOString(), apply: APPLY, worklist: TSV, created: [], skipped: [], failed: [], flagged: [] };
+
+  // ---- preflight: vendor-guard, price-ceiling, compute price ----
+  const work = [];
+  for (const r of rows) {
+    if (!/^hollywood wallcoverings$/i.test(r.vendor)) { console.log(`🚫 ${r.gid} — non-Hollywood vendor "${r.vendor}" — SKIP (guard)`); report.skipped.push({ ...r, reason: 'vendor-guard' }); continue; }
+    if (!(r.hw_price > 0)) { console.log(`⏭  ${r.gid} ${r.mfr_sku} — bad hw_price ${r.hw_price}`); report.skipped.push({ ...r, reason: 'no-price' }); continue; }
+    if (r.hw_price < PRICE_MIN || r.hw_price > PRICE_MAX) { console.log(`⚑ ${r.gid} ${r.mfr_sku} — hw_price $${r.hw_price} outside $${PRICE_MIN}–$${PRICE_MAX} — FLAG+SKIP`); report.flagged.push({ ...r, reason: 'price-ceiling' }); continue; }
+    const { rule, price } = priceForHollywood(r.hw_price);
+    work.push({ ...r, rule, price });
+  }
+
+  // ---- PG-FIRST ----
+  if (APPLY && work.length) {
+    try {
+      const res = pgWriteRollPrice(work.map(w => ({ gid: w.gid, price: w.price })));
+      console.log(`PG (dw_unified) retail_price written — updated count: ${res}\n`);
+      report.pgUpdated = res;
+    } catch (e) { console.error('PG write FAILED — aborting before Shopify:', e.message || e); process.exit(2); }
+  } else if (work.length) {
+    console.log(`(dry-run) would write ${work.length} retail_price rows to dw_unified first\n`);
+  }
+
+  // ---- Shopify: add roll variant per product ----
+  for (const w of work) {
+    const pr = await gqlR(Q_PRODUCT, { id: w.gid });
+    const p = pr.data && pr.data.product;
+    if (!p) { console.log(`❌ ${w.gid} ${w.mfr_sku} not found`); report.failed.push({ ...w, reason: 'not-found' }); continue; }
+
+    const vs = p.variants.nodes;
+    const sample = vs.find(v => /sample/i.test(v.title) || /-sample$/i.test(v.sku || ''));
+    const roll = vs.find(v => v !== sample);
+    if (roll) { console.log(`⏭  ${p.title.slice(0,48)} already has roll (${roll.sku})`); report.skipped.push({ ...w, reason: 'already-has-roll', existingRollSku: roll.sku }); continue; }
+
+    let skuBase = (sample && sample.sku) ? sample.sku.replace(/-sample$/i, '') : w.mfr_sku;
+    if (!skuBase) { console.log(`❌ ${w.gid} — no derivable SKU — SKIP`); report.failed.push({ ...w, reason: 'no-sku' }); continue; }
+
+    console.log(`${APPLY ? '➕' : '·'} ${p.title.slice(0, 46).padEnd(46)} [${p.status}] roll $${w.price} (hw_price direct) sku ${skuBase}`);
+    if (!APPLY) { report.created.push({ ...w, skuBase, dryRun: true }); continue; }
+
+    const cr = await gqlR(M_VAR_CREATE, { pid: w.gid, variants: [{
+      optionValues: [{ optionName: (p.options[0] && p.options[0].name) || 'Title', name: 'Single Roll' }],
+      price: String(w.price), taxable: true, inventoryPolicy: 'CONTINUE',
+      inventoryItem: { sku: skuBase, tracked: false },     // no invented cost for Hollywood
+    }] });
+    const ue = cr.data?.productVariantsBulkCreate?.userErrors || [];
+    if (ue.length) { console.log(`   ❌ create: ${JSON.stringify(ue)}`); report.failed.push({ ...w, skuBase, reason: 'create-error', errors: ue }); continue; }
+    const newId = cr.data.productVariantsBulkCreate.productVariants[0].id;
+
+    if (sample) {
+      const ro = await gqlR(M_REORDER, { pid: w.gid, positions: [{ id: newId, position: 1 }, { id: sample.id, position: 2 }] });
+      const re = ro.data?.productVariantsBulkReorder?.userErrors || [];
+      if (re.length) console.log(`   ⚠ reorder: ${JSON.stringify(re)}`);
+    }
+
+    const mf = await gqlR(M_MF, { mf: [
+      { ownerId: w.gid, namespace: 'custom', key: 'price_updated_at', type: 'date', value: TODAY },
+      { ownerId: w.gid, namespace: 'global', key: 'unit_of_measure', type: 'single_line_text_field', value: 'Priced Per Single Roll' },
+    ] });
+    const me = mf.data?.metafieldsSet?.userErrors || [];
+    if (me.length) console.log(`   ⚠ metafields: ${JSON.stringify(me)}`);
+
+    report.created.push({ ...w, skuBase, variantId: newId });
+    await sleep(180);
+  }
+
+  report.finished = new Date().toISOString();
+  report.totals = { created: report.created.length, skipped: report.skipped.length, failed: report.failed.length, flagged: report.flagged.length };
+  console.log(`\n=== SUMMARY ===`);
+  console.log(`created=${report.totals.created} skipped=${report.totals.skipped} failed=${report.totals.failed} flagged=${report.totals.flagged}`);
+  if (REPORT) { fs.mkdirSync(path.dirname(REPORT), { recursive: true }); fs.writeFileSync(REPORT, JSON.stringify(report, null, 2)); console.log(`report -> ${REPORT}`); }
+})();

← 4a9a4727 auto-save: 2026-07-27T14:21:14 (1 files) — mailers/cc-api/im  ·  back to Designer Wallcoverings  ·  auto-save: 2026-07-27T16:52:13 (2 files) — shopify/scripts/a 69f163b6 →