← 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
A scripts/tk11323-twil-8yd/apply-8yd.mjsA scripts/tk11732-pj-showroom-tag/apply-pj-showroomonly.mjs
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 →