← 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
A shopify/scripts/tk17-batchA-quotes-tag.jsA shopify/scripts/tk17-batchB-hollywood-roll-variants.js
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 →