[object Object]

← back to Designerwallcoverings

TK-11732/TK-11323: isolated PJ ShowroomOnly tagger + Fentucci 8yd whole-bolt applier (reversible, ledgered)

eebbdfbf85845cfe89ba1824efc58d54d6826f85 · 2026-09-14 13:25:55 -0700 · vp-dw-commerce

Files touched

Diff

commit eebbdfbf85845cfe89ba1824efc58d54d6826f85
Author: vp-dw-commerce <steve@designerwallcoverings.com>
Date:   Mon Sep 14 13:25:55 2026 -0700

    TK-11732/TK-11323: isolated PJ ShowroomOnly tagger + Fentucci 8yd whole-bolt applier (reversible, ledgered)
---
 scripts/tk11323-twil-8yd/apply-8yd.mjs             | 184 ++++++++++++++++
 .../apply-pj-showroomonly.mjs                      | 240 +++++++++++++++++++++
 2 files changed, 424 insertions(+)

diff --git a/scripts/tk11323-twil-8yd/apply-8yd.mjs b/scripts/tk11323-twil-8yd/apply-8yd.mjs
new file mode 100644
index 0000000..525e40e
--- /dev/null
+++ b/scripts/tk11323-twil-8yd/apply-8yd.mjs
@@ -0,0 +1,184 @@
+#!/usr/bin/env node
+/**
+ * TK-11323 — apply the min-8 / step-8 whole-bolt rule to the 107 ACTIVE Fentucci grasscloth
+ * Per-Yard SKUs that were imported AFTER the 2026-09-09 run and never got it (currently buyable
+ * at 1-yard minimums, violating the 8-yд-whole-bolt directive). Packaging only — NO price writes.
+ *
+ * Change (per treated-153 pattern + update-product-min skill): on each product's "Per Yard"
+ * sellable variant set variant metafields
+ *     custom.v_prod_quantity_order_min   = "8"   (single_line_text_field)
+ *     custom.v_prods_quantity_order_units = "8"  (single_line_text_field)
+ * Sample variant untouched.
+ *
+ * SAFETY
+ *   - Skips any Per-Yard variant whose price is $0/null (don't lock a min onto a broken-price product).
+ *   - Idempotent: re-verifies both metafields are ABSENT immediately before writing; skips if present.
+ *   - REVERSIBLE: records, per variant, which of the two metafields we CREATED (were absent). --rollback
+ *     metafieldsDelete ONLY those we created — never deletes a metafield that pre-existed.
+ *   - Restore map written BEFORE any write.
+ *
+ * MODES
+ *   (default)  DRY-RUN — resolve variants, write restore map, report would-write. No Shopify writes.
+ *   --apply    resolve -> restore map -> metafieldsSet where absent.
+ *   --rollback read newest restore map -> metafieldsDelete the metafields we created (combine with --apply).
+ *
+ * Cost: $0 (Admin API).
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+import { gql, SHOP } from '../lib/shopify.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const DATA = path.join(__dirname, 'data');
+const SRC = process.env.HOME + '/.claude/yolo-queue/pending-approval/assets/TK-11323-twil-8yd-gap-107.json';
+const LOG_EXEC = process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs';
+const NS = 'custom';
+const K_MIN = 'v_prod_quantity_order_min';
+const K_UNITS = 'v_prods_quantity_order_units';
+const VAL = '8';
+const MTYPE = 'single_line_text_field';
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const ROLLBACK = args.includes('--rollback');
+const READ_BATCH = 40;
+const WRITE_PAUSE_MS = 250;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const ledger = rec => { try { execFileSync('node', [LOG_EXEC], { input: JSON.stringify(rec) }); } catch (e) { console.error('  [ledger WARN]', e.message); } };
+
+function loadSrc() {
+  const raw = JSON.parse(fs.readFileSync(SRC, 'utf8'));
+  return raw.map(r => ({ gid: r.gid, sku: r.sku, title: r.title }));
+}
+
+// Resolve each product's Per-Yard sellable variant + current metafield presence.
+async function resolve(products) {
+  const out = [];
+  for (let i = 0; i < products.length; i += READ_BATCH) {
+    const chunk = products.slice(i, i + READ_BATCH);
+    const q = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id title status vendor
+      variants(first:20){ nodes{ id title price
+        mMin: metafield(namespace:"${NS}", key:"${K_MIN}"){ id value }
+        mUnits: metafield(namespace:"${NS}", key:"${K_UNITS}"){ id value }
+      } } } } }`;
+    const d = await gql(q, { ids: chunk.map(c => c.gid) });
+    if (d?.__err) throw new Error('resolve gql err: ' + JSON.stringify(d.__err).slice(0, 300));
+    for (const n of (d.nodes || [])) {
+      if (!n) continue;
+      const src = chunk.find(c => c.gid === n.id);
+      const py = (n.variants?.nodes || []).find(v => /per yard/i.test(v.title || ''));
+      out.push({
+        gid: n.id, title: n.title, status: n.status, vendor: n.vendor, sku: src?.sku,
+        variant: py ? {
+          id: py.id, title: py.title, price: py.price,
+          has_min: !!py.mMin, min_val: py.mMin?.value ?? null,
+          has_units: !!py.mUnits, units_val: py.mUnits?.value ?? null,
+        } : null,
+      });
+    }
+    process.stdout.write(`\r  resolve ${Math.min(i + READ_BATCH, products.length)}/${products.length}`);
+  }
+  process.stdout.write('\n');
+  return out;
+}
+
+async function setMetafield(ownerId, key, value) {
+  const m = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ field message } } }`;
+  const d = await gql(m, { mf: [{ ownerId, namespace: NS, key, value, type: MTYPE }] });
+  if (d?.__err) return { ok: false, err: JSON.stringify(d.__err).slice(0, 200) };
+  const ue = d.metafieldsSet?.userErrors || [];
+  return { ok: ue.length === 0, err: ue.map(e => e.message).join('; ') || null };
+}
+async function delMetafield(ownerId, key) {
+  const m = `mutation($mf:MetafieldIdentifierInput!){ metafieldDelete: metafieldsDelete(metafields:[$mf]){ deletedMetafields{ key } userErrors{ message } } }`;
+  // metafieldsDelete takes identifiers {ownerId,namespace,key}
+  const d = await gql(`mutation($ids:[MetafieldIdentifierInput!]!){ metafieldsDelete(metafields:$ids){ deletedMetafields{ ownerId key } userErrors{ message } } }`,
+    { ids: [{ ownerId, namespace: NS, key }] });
+  if (d?.__err) return { ok: false, err: JSON.stringify(d.__err).slice(0, 200) };
+  const ue = d.metafieldsDelete?.userErrors || [];
+  return { ok: ue.length === 0, err: ue.map(e => e.message).join('; ') || null };
+}
+
+function restoreMapFiles() {
+  const files = fs.readdirSync(DATA).filter(f => /^restore-map-.*\.json$/.test(f)).sort();
+  if (!files.length) throw new Error('no restore-map in data/ — run --apply (or dry-run) first');
+  return files.map(f => path.join(DATA, f));
+}
+function resolveBaseline() {
+  // earliest-wins across all maps (a dry-run after apply would show present=true; never overwrite baseline)
+  const byVar = new Map();
+  for (const f of restoreMapFiles()) {
+    let map; try { map = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { continue; }
+    for (const r of (map.rows || [])) { if (r.variant_id && !byVar.has(r.variant_id)) byVar.set(r.variant_id, r); }
+  }
+  return byVar;
+}
+
+(async () => {
+  const products = loadSrc();
+  console.log(`TK-11323 apply 8/8 whole-bolt — ${ROLLBACK ? 'ROLLBACK' : APPLY ? 'APPLY' : 'DRY-RUN'} → ${SHOP}`);
+  console.log(`  source: ${products.length} Fentucci Per-Yard SKUs`);
+
+  if (ROLLBACK) {
+    const baseline = resolveBaseline();
+    const rows = [...baseline.values()];
+    // delete only metafields we CREATED (created_min / created_units true)
+    const ops = [];
+    for (const r of rows) {
+      if (r.created_min) ops.push({ variant_id: r.variant_id, key: K_MIN });
+      if (r.created_units) ops.push({ variant_id: r.variant_id, key: K_UNITS });
+    }
+    console.log(`  will metafieldsDelete ${ops.length} metafields we created (across ${rows.filter(r=>r.created_min||r.created_units).length} variants).`);
+    if (!APPLY) { console.log('\n  ROLLBACK preview only. Re-run: --rollback --apply'); return; }
+    let ok = 0, fail = 0;
+    for (const o of ops) { const r = await delMetafield(o.variant_id, o.key); if (r.ok) ok++; else { fail++; console.error('  DELFAIL', o.variant_id, o.key, r.err); } await sleep(WRITE_PAUSE_MS); }
+    console.log(`  deleted OK: ${ok}  failed: ${fail}`);
+    ledger({ agent: process.env.TK_AGENT || 'vp-dw-commerce', ticket: 'TK-11323',
+      action: `ROLLBACK metafieldsDelete 8/8 whole-bolt on ${ok} Fentucci Per-Yard metafields`, blast_radius: ops.length,
+      undo_cmd: `node ${path.join(__dirname, 'apply-8yd.mjs')} --apply`, verify: 'inspect variant metafields' });
+    return;
+  }
+
+  const resolved = await resolve(products);
+  const runtag = new Date().toISOString().replace(/[:.]/g, '-');
+  const noVariant = resolved.filter(r => !r.variant);
+  const notActive = resolved.filter(r => r.status !== 'ACTIVE');
+  const notFentucci = resolved.filter(r => r.vendor !== 'Fentucci');
+  const zeroPrice = resolved.filter(r => r.variant && (!r.variant.price || parseFloat(r.variant.price) <= 0));
+  const alreadyRuled = resolved.filter(r => r.variant && r.variant.has_min && r.variant.has_units);
+  const targets = resolved.filter(r => r.variant && parseFloat(r.variant.price) > 0 && !(r.variant.has_min && r.variant.has_units));
+
+  // restore map (pre-state) — records what we WILL create so rollback deletes only those
+  const rows = targets.map(r => ({
+    gid: r.gid, sku: r.sku, variant_id: r.variant.id, variant_title: r.variant.title, price: r.variant.price,
+    had_min: r.variant.has_min, had_units: r.variant.has_units,
+    created_min: !r.variant.has_min, created_units: !r.variant.has_units,
+  }));
+  fs.writeFileSync(path.join(DATA, `restore-map-${runtag}.json`), JSON.stringify({ ticket: 'TK-11323', shop: SHOP, created_at: new Date().toISOString(), source: SRC, rows }, null, 2));
+
+  console.log(`  resolved: ${resolved.length}   no Per-Yard variant: ${noVariant.length}   not ACTIVE: ${notActive.length}   not Fentucci: ${notFentucci.length}`);
+  console.log(`  skip $0/null price: ${zeroPrice.length}   already fully ruled: ${alreadyRuled.length}`);
+  console.log(`  WOULD set 8/8 on: ${targets.length} Per-Yard variants`);
+  if (notFentucci.length) { console.error(`  ABORT: ${notFentucci.length} live targets are not Fentucci.`); process.exit(3); }
+
+  if (!APPLY) { console.log('\nDRY-RUN only — no Shopify writes. Re-run with --apply.'); return; }
+
+  let okV = 0, failV = 0; const applied = [];
+  for (const r of targets) {
+    const vid = r.variant.id;
+    let vok = true;
+    if (!r.variant.has_min) { const a = await setMetafield(vid, K_MIN, VAL); if (!a.ok) { vok = false; console.error('  SETFAIL min', r.sku, a.err); } await sleep(WRITE_PAUSE_MS); }
+    if (!r.variant.has_units) { const b = await setMetafield(vid, K_UNITS, VAL); if (!b.ok) { vok = false; console.error('  SETFAIL units', r.sku, b.err); } await sleep(WRITE_PAUSE_MS); }
+    if (vok) { okV++; applied.push(vid); } else failV++;
+    process.stdout.write(`\r  set ${okV + failV}/${targets.length}`);
+  }
+  process.stdout.write('\n');
+  fs.appendFileSync(path.join(DATA, `applied-${runtag}.jsonl`), applied.map(v => JSON.stringify({ variant_id: v, ts: Date.now() })).join('\n') + '\n');
+  console.log(`  variants set OK: ${okV}   failed: ${failV}`);
+  ledger({ agent: process.env.TK_AGENT || 'vp-dw-commerce', ticket: 'TK-11323',
+    action: `metafieldsSet 8/8 whole-bolt (min=8/units=8) on ${okV} ACTIVE Fentucci Per-Yard variants (packaging only, no price writes)`, blast_radius: targets.length,
+    undo_cmd: `node ${path.join(__dirname, 'apply-8yd.mjs')} --rollback --apply`, verify: 'inspect custom.v_prod_quantity_order_min / v_prods_quantity_order_units on the Per-Yard variants' });
+  console.log('\nAPPLY complete.');
+})().catch(e => { console.error('\nFATAL', e.message); process.exit(1); });
diff --git a/scripts/tk11732-pj-showroom-tag/apply-pj-showroomonly.mjs b/scripts/tk11732-pj-showroom-tag/apply-pj-showroomonly.mjs
new file mode 100644
index 0000000..d810a73
--- /dev/null
+++ b/scripts/tk11732-pj-showroom-tag/apply-pj-showroomonly.mjs
@@ -0,0 +1,240 @@
+#!/usr/bin/env node
+/**
+ * TK-11732 — stamp the EXACT tag `ShowroomOnly` on the 3,392 ACTIVE Phillip Jeffries
+ * product IDs so the browse/search grids can suppress them (PJ is a showroom-only line).
+ *
+ * ISOLATED sibling of tk11307-showroom-tag/apply-showroomonly.mjs (which is hardwired to the
+ * MDC/Phillipe Romano id list and whose loadTargets() IGNORES --map). This one reads the PJ
+ * target list explicitly and keeps its OWN restore-map lineage in ./data so PJ rollback can
+ * never pull in MDC rows and vice-versa.
+ *
+ * Target list: ~/.claude/yolo-queue/pending-approval/TK-11732-pj-fleet-target-list.json
+ *   (3,392 rows {legacy,status,handle,had_tag}; VERIFIED = exactly vendor='Phillip Jeffries'
+ *    AND status='ACTIVE' in the dw_unified mirror; 0 non-PJ, 0 missing, all had_tag=false.)
+ *
+ * SAFETY / DESIGN (identical doctrine to the MDC script)
+ *   - tagsAdd ONLY (never tagsSet): additive + idempotent; other tags never touched.
+ *   - EXACT tag string 'ShowroomOnly'.
+ *   - REVERSIBLE: a per-product LIVE-prescan restore map (had_tag) is written BEFORE any write.
+ *     --rollback removes the tag ONLY from products where we added it (had_tag=false).
+ *   - RESUMABLE: applied ids appended to data/applied-<runtag>.jsonl; re-run skips them.
+ *   - THROTTLED via the shared lib/shopify.mjs gql().
+ *
+ * MODES
+ *   (default)   DRY-RUN — prescan live tags, write restore map, report would-write. No Shopify writes.
+ *   --apply     prescan -> restore map -> tagsAdd where missing.
+ *   --rollback  read newest restore map -> tagsRemove where we added (combine with --apply to execute).
+ *   --verify    count live products with EXACT tag 'ShowroomOnly' and PJ vendor.
+ *
+ * Cost: $0 (Admin API, no per-call charge).
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+import { gql, SHOP } from '../lib/shopify.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const DATA = path.join(__dirname, 'data');
+const TARGET_FILE = process.env.HOME + '/.claude/yolo-queue/pending-approval/TK-11732-pj-fleet-target-list.json';
+const TAG = 'ShowroomOnly';
+const TAG_LC = TAG.toLowerCase();
+const LOG_EXEC = process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs';
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const ROLLBACK = args.includes('--rollback');
+const VERIFY = args.includes('--verify');
+const _mapIdx = args.indexOf('--map');
+const MAP_ARG = _mapIdx > -1 && args[_mapIdx + 1] ? path.resolve(process.cwd(), args[_mapIdx + 1]) : null;
+const READ_BATCH = 250;
+const WRITE_BATCH = 20;
+const BATCH_PAUSE_MS = 300;
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const ledger = rec => { try { execFileSync('node', [LOG_EXEC], { input: JSON.stringify(rec) }); } catch (e) { console.error('  [ledger WARN]', e.message); } };
+
+function loadTargets() {
+  const raw = JSON.parse(fs.readFileSync(TARGET_FILE, 'utf8'));
+  if (!Array.isArray(raw)) throw new Error('target list is not an array');
+  const seen = new Set();
+  const targets = [];
+  for (const e of raw) {
+    const legacy = String(e.legacy);
+    if (!/^\d+$/.test(legacy)) throw new Error('non-numeric legacy id: ' + legacy);
+    if (seen.has(legacy)) continue;
+    seen.add(legacy);
+    targets.push({ gid: `gid://shopify/Product/${legacy}`, legacy, status: e.status || '?' });
+  }
+  return targets;
+}
+
+async function prescan(targets) {
+  const byGid = new Map();
+  for (let i = 0; i < targets.length; i += READ_BATCH) {
+    const chunk = targets.slice(i, i + READ_BATCH);
+    const q = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id vendor status tags } } }`;
+    const d = await gql(q, { ids: chunk.map(t => t.gid) });
+    if (d?.__err) throw new Error('prescan gql err: ' + JSON.stringify(d.__err).slice(0, 300));
+    for (const n of (d.nodes || [])) {
+      if (!n) continue;
+      const tagsLc = (n.tags || []).map(t => String(t).trim().toLowerCase());
+      byGid.set(n.id, { had_tag: tagsLc.includes(TAG_LC), tags_before: n.tags || [], vendor: n.vendor, status: n.status });
+    }
+    process.stdout.write(`\r  prescan ${Math.min(i + READ_BATCH, targets.length)}/${targets.length}`);
+  }
+  process.stdout.write('\n');
+  return byGid;
+}
+
+function writeRestoreMap(targets, byGid, runtag) {
+  const rows = targets.map(t => {
+    const info = byGid.get(t.gid);
+    return {
+      gid: t.gid, legacy: t.legacy, status: t.status,
+      present_in_store: !!info,
+      had_tag: info ? info.had_tag : null,
+      vendor_live: info ? info.vendor : null,
+      status_live: info ? info.status : null,
+      tags_before: info ? info.tags_before : null,
+    };
+  });
+  const file = path.join(DATA, `restore-map-${runtag}.json`);
+  fs.writeFileSync(file, JSON.stringify({
+    ticket: 'TK-11732', tag: TAG, shop: SHOP, created_at: new Date().toISOString(),
+    source_list: TARGET_FILE, target_count: targets.length, rows,
+  }, null, 2));
+  return { file, rows };
+}
+
+function restoreMapFiles() {
+  const files = fs.readdirSync(DATA).filter(f => /^restore-map-.*\.json$/.test(f)).sort();
+  if (!files.length) throw new Error('no restore-map found in data/ — run --apply (or dry-run) first');
+  return files.map(f => path.join(DATA, f));
+}
+
+// earliest-wins baseline reconstruction (a dry-run after apply records had_tag=true for all;
+// trusting the newest map would compute "added to 0" and silently no-op the undo).
+function resolveBaseline(explicitMap) {
+  const files = explicitMap ? [explicitMap] : restoreMapFiles();
+  const byGid = new Map();
+  const used = [];
+  for (const file of files) {
+    let map;
+    try { map = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; }
+    if (!Array.isArray(map.rows)) continue;
+    let contributed = 0;
+    for (const r of map.rows) {
+      if (!r || !r.gid) continue;
+      if (byGid.has(r.gid)) continue;
+      byGid.set(r.gid, r); contributed++;
+    }
+    used.push({ file: path.basename(file), rows: map.rows.length, contributed });
+  }
+  return { byGid, used };
+}
+
+async function batchTagOp(op, gids) {
+  const results = [];
+  for (let i = 0; i < gids.length; i += WRITE_BATCH) {
+    const chunk = gids.slice(i, i + WRITE_BATCH);
+    const m = 'mutation{' + chunk.map((g, j) =>
+      `m${j}: ${op}(id:"${g}", tags:["${TAG}"]){ userErrors{ field message } }`).join(' ') + '}';
+    const d = await gql(m);
+    if (d?.__err) { chunk.forEach(g => results.push({ gid: g, ok: false, err: JSON.stringify(d.__err).slice(0, 200) })); }
+    else chunk.forEach((g, j) => {
+      const ue = d[`m${j}`]?.userErrors || [];
+      results.push({ gid: g, ok: ue.length === 0, err: ue.map(e => e.message).join('; ') || null });
+    });
+    process.stdout.write(`\r  ${op} ${Math.min(i + WRITE_BATCH, gids.length)}/${gids.length}`);
+    await sleep(BATCH_PAUSE_MS);
+  }
+  process.stdout.write('\n');
+  return results;
+}
+
+async function doVerify(targets) {
+  let count = 0;
+  const d = await gql(`query{ productsCount(query:"tag:'${TAG}' AND vendor:'Phillip Jeffries'"){ count } }`);
+  if (d && !d.__err && d.productsCount) count = d.productsCount.count;
+  console.log(`\n  live PJ products with EXACT tag '${TAG}': ${count}`);
+  console.log(`  expected (target set): ${targets.length}`);
+  console.log(`  ${count === targets.length ? 'MATCH ✓' : 'MISMATCH — investigate'}`);
+  return count;
+}
+
+(async () => {
+  const targets = loadTargets();
+  const active = targets.filter(t => t.status === 'ACTIVE').length;
+  console.log(`TK-11732 apply '${TAG}' — ${ROLLBACK ? 'ROLLBACK' : VERIFY ? 'VERIFY' : APPLY ? 'APPLY' : 'DRY-RUN'} → ${SHOP}`);
+  console.log(`  target set: ${targets.length}  (ACTIVE ${active})`);
+
+  if (VERIFY) { await doVerify(targets); return; }
+
+  if (ROLLBACK) {
+    const { byGid: baseline, used } = resolveBaseline(MAP_ARG);
+    console.log(MAP_ARG ? `  restore map (explicit --map): ${MAP_ARG}`
+                        : `  reconstructing baseline from ${used.length} restore map(s), earliest-wins:`);
+    for (const u of used) console.log(`    ${u.file}  rows=${u.rows}  contributed=${u.contributed}`);
+    const rows = [...baseline.values()];
+    const toRemove = rows.filter(r => r.present_in_store && r.had_tag === false).map(r => r.gid);
+    const preserved = rows.filter(r => r.had_tag === true).length;
+    console.log(`  will tagsRemove '${TAG}' from ${toRemove.length} products (preserving ${preserved} that had it before).`);
+    if (toRemove.length === 0) {
+      const live = await doVerify(targets);
+      if (live > 0) {
+        console.error(`\n  ROLLBACK ABORTED — computed 0 to untag but '${TAG}' is live on ${live}. Baseline unusable; pass pre-apply map with --map.`);
+        process.exitCode = 1; return;
+      }
+      console.log(`\n  Nothing to remove and tag live on 0 — already rolled back. OK`); return;
+    }
+    if (!APPLY) { console.log('\n  ROLLBACK preview only. Re-run: --rollback --apply'); return; }
+    const res = await batchTagOp('tagsRemove', toRemove);
+    const fails = res.filter(r => !r.ok);
+    console.log(`  removed OK: ${res.length - fails.length}  failed: ${fails.length}`);
+    if (fails.length) fs.writeFileSync(path.join(DATA, `rollback-failures-${Date.now()}.json`), JSON.stringify(fails, null, 2));
+    ledger({ agent: process.env.TK_AGENT || 'vp-dw-commerce', ticket: 'TK-11732',
+      action: `ROLLBACK tagsRemove '${TAG}' from ${res.length - fails.length} PJ products`, blast_radius: toRemove.length,
+      undo_cmd: `node ${path.join(__dirname, 'apply-pj-showroomonly.mjs')} --apply`, verify: `--verify` });
+    return;
+  }
+
+  const runtag = new Date().toISOString().replace(/[:.]/g, '-');
+  console.log('  prescanning live tags (reversibility baseline)...');
+  const byGid = await prescan(targets);
+  const found = byGid.size;
+  const alreadyTagged = [...byGid.values()].filter(v => v.had_tag).length;
+  const missing = targets.filter(t => byGid.has(t.gid) && !byGid.get(t.gid).had_tag);
+  // GUARD: never write to a product the live prescan says is NOT Phillip Jeffries.
+  const nonPJ = [...byGid.entries()].filter(([, v]) => v.vendor !== 'Phillip Jeffries');
+  const notFound = targets.length - found;
+  const { file } = writeRestoreMap(targets, byGid, runtag);
+  console.log(`  restore map written: ${file}`);
+  console.log(`  found in store: ${found}   not found: ${notFound}`);
+  console.log(`  live vendor != 'Phillip Jeffries': ${nonPJ.length}`);
+  console.log(`  already carry '${TAG}': ${alreadyTagged}`);
+  console.log(`  WOULD tagsAdd '${TAG}' on: ${missing.length}`);
+  if (nonPJ.length) { console.error(`  ABORT: ${nonPJ.length} live targets are not Phillip Jeffries — refusing to write.`); process.exit(3); }
+
+  if (!APPLY) { console.log(`\nDRY-RUN only — no Shopify writes. Re-run with --apply.`); return; }
+
+  const appliedFile = path.join(DATA, `applied-${runtag}.jsonl`);
+  const alreadyApplied = new Set();
+  for (const f of fs.readdirSync(DATA).filter(f => /^applied-.*\.jsonl$/.test(f))) {
+    for (const line of fs.readFileSync(path.join(DATA, f), 'utf8').split('\n').filter(Boolean)) {
+      try { const r = JSON.parse(line); if (r.ok) alreadyApplied.add(r.gid); } catch {}
+    }
+  }
+  const toApply = missing.filter(t => !alreadyApplied.has(t.gid)).map(t => t.gid);
+  console.log(`  applying to ${toApply.length} (skipping ${missing.length - toApply.length} already-applied)`);
+  const res = await batchTagOp('tagsAdd', toApply);
+  const okRes = res.filter(r => r.ok), fails = res.filter(r => !r.ok);
+  fs.appendFileSync(appliedFile, res.map(r => JSON.stringify({ ...r, ts: Date.now() })).join('\n') + '\n');
+  console.log(`  tagged OK: ${okRes.length}   failed: ${fails.length}`);
+  if (fails.length) { const ff = path.join(DATA, `apply-failures-${runtag}.json`); fs.writeFileSync(ff, JSON.stringify(fails, null, 2)); console.log(`  failures → ${ff}`); }
+  ledger({ agent: process.env.TK_AGENT || 'vp-dw-commerce', ticket: 'TK-11732',
+    action: `tagsAdd '${TAG}' on ${okRes.length} ACTIVE Phillip Jeffries products (showroom-only enforcement)`, blast_radius: toApply.length,
+    undo_cmd: `node ${path.join(__dirname, 'apply-pj-showroomonly.mjs')} --rollback --apply`,
+    verify: `node ${path.join(__dirname, 'apply-pj-showroomonly.mjs')} --verify` });
+  console.log(`\nAPPLY complete. Verify: node apply-pj-showroomonly.mjs --verify`);
+})().catch(e => { console.error('\nFATAL', e.message); process.exit(1); });

← 5c08e4b auto-data-snapshot: 2026-09-14T09:19:35 (1 data files) — dat  ·  back to Designerwallcoverings  ·  auto-data-snapshot: 2026-09-14T13:27:58 (7 data files) — scr e916ab2 →