[object Object]

← back to Designerwallcoverings

TK-11046: Sanderson Dalmatians activate + dup-draft cleanup + rollback-map

f32a9c192cbe74b25f8ced8b0e63aeb327104865 · 2026-09-04 12:42:39 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STupyqBoLfTPHaTr1Gi42b

Files touched

Diff

commit f32a9c192cbe74b25f8ced8b0e63aeb327104865
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 4 12:42:39 2026 -0700

    TK-11046: Sanderson Dalmatians activate + dup-draft cleanup + rollback-map
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01STupyqBoLfTPHaTr1Gi42b
---
 .../activate-dalmatians-TK-11046.mjs               | 105 +++++++++++++++++++++
 .../delete-dup-drafts-TK-11046.mjs                 |  98 +++++++++++++++++++
 scripts/sanderson-onboard/rollback-map-build.mjs   |  71 ++++++++++++++
 3 files changed, 274 insertions(+)

diff --git a/scripts/sanderson-onboard/activate-dalmatians-TK-11046.mjs b/scripts/sanderson-onboard/activate-dalmatians-TK-11046.mjs
new file mode 100644
index 0000000..6bb745f
--- /dev/null
+++ b/scripts/sanderson-onboard/activate-dalmatians-TK-11046.mjs
@@ -0,0 +1,105 @@
+// TK-11046 — activate the 2 settlement-approved Dalmatians (Steve cleared REVIEW-VISION 2026-09-02).
+// Mirrors go-live.mjs exactly: inventory on sellable Roll variant -> publish 12 channels (Google
+// excluded) -> status ACTIVE (last). Overrides ONLY the settlement gate; still enforces the
+// 5-field+image validate() and the TK-10303 manufacturer_sku metafield gate. Snapshot + ledger.
+// --apply to execute; default DRY-RUN.
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { gql, SHOP } from '../lib/shopify.mjs';
+
+const APPLY = process.argv.includes('--apply');
+const __dir = path.dirname(fileURLToPath(import.meta.url));
+const outDir = path.join(__dir, 'out');
+
+// EXACT constants copied from go-live.mjs (must match).
+const LOCATION_ID = 'gid://shopify/Location/5795643504';   // 15442 Ventura Blvd.
+const TARGET_QTY = 2026;
+const PUBLICATIONS = [
+  22208643184, 22497296496, /* Google-EXCLUDED */ 29739483201, 29776969793, 37904089153,
+  43657658419, 44234276915, 44317474867, 44317507635, 71898464307, 115856375859, 140027723827,
+].map(n => `gid://shopify/Publication/${n}`);
+
+// Steve-approved settlement override targets (verified NOT duplicates; no canonical 600xxx sibling).
+const TARGETS = [
+  { id: '7899582201907', sku: 'DWXH-255000', handle: '101-dalmatians-breeze-blue-sanderson' },
+  { id: '7899582431283', sku: 'DWXH-255001', handle: '101-dalmatians-candy-floss-sanderson' },
+];
+
+const gidP = id => `gid://shopify/Product/${id}`;
+const Q_PROD = `query($id:ID!){ product(id:$id){ status handle descriptionHtml tags featuredImage{url}
+  mediaCount{count} variants(first:10){edges{node{ sku price inventoryItem{id} }}} } }`;
+const M_TRACK = `mutation($id:ID!){ inventoryItemUpdate(id:$id, input:{tracked:true}){ userErrors{message} } }`;
+const M_ACTIVATE = `mutation($iid:ID!,$loc:ID!){ inventoryActivate(inventoryItemId:$iid, locationId:$loc){ userErrors{message} } }`;
+const M_SETQTY = `mutation($input:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$input){ userErrors{message code} } }`;
+const M_PUBLISH = `mutation($id:ID!,$pubs:[PublicationInput!]!){ publishablePublish(id:$id, input:$pubs){ userErrors{message} } }`;
+const M_ACTIVE = `mutation($id:ID!){ productUpdate(input:{id:$id, status:ACTIVE}){ product{status} userErrors{message} } }`;
+
+function validate(p) {
+  const fails = [];
+  const vs = (p.variants?.edges || []).map(e => e.node);
+  const sample = vs.find(v => /-sample$/i.test(v.sku || ''));
+  const sellable = vs.find(v => v && !/-sample$/i.test(v.sku || ''));
+  if (!sample) fails.push('sample-variant');
+  if (!sellable) fails.push('sellable-variant');
+  if (!sellable || !(Number(sellable.price) > 0)) fails.push('pricing');
+  if (!p.descriptionHtml || !p.descriptionHtml.replace(/<[^>]*>/g, '').trim()) fails.push('description');
+  if (!p.tags || p.tags.length < 2) fails.push('tags>=2');
+  if (!(p.featuredImage?.url) && !(p.mediaCount?.count > 0)) fails.push('image');
+  return { fails, sample, sellable };
+}
+
+const snapshots = [], results = [];
+const ledgerPath = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl');
+
+for (const t of TARGETS) {
+  const d = await gql(Q_PROD, { id: gidP(t.id) });
+  const p = d?.product;
+  if (!p) { results.push({ ...t, action: 'SKIP', reason: 'not found' }); continue; }
+  if (p.handle !== t.handle) { results.push({ ...t, action: 'SKIP', reason: `handle mismatch (${p.handle})` }); continue; }
+  if (p.status === 'ACTIVE') { results.push({ ...t, action: 'ALREADY-ACTIVE' }); continue; }
+
+  // Non-settlement gates still enforced.
+  const { fails, sellable } = validate(p);
+  if (fails.length) { results.push({ ...t, action: 'HELD', reason: 'validate:' + fails.join('+') }); continue; }
+  const mf = await gql('query($id:ID!){product(id:$id){c:metafield(namespace:"custom",key:"manufacturer_sku"){value} d:metafield(namespace:"dwc",key:"manufacturer_sku"){value}}}', { id: gidP(t.id) });
+  if (!((mf?.product?.c?.value || mf?.product?.d?.value || '').trim())) { results.push({ ...t, action: 'HELD', reason: 'no manufacturer_sku metafield (TK-10303)' }); continue; }
+
+  snapshots.push({ target: t, pre_status: p.status, sellable_sku: sellable?.sku });
+  if (!APPLY) { results.push({ ...t, action: 'WOULD-ACTIVATE', pre: p.status }); continue; }
+
+  const errs = [];
+  const iid = sellable.inventoryItem?.id;
+  if (iid) {
+    let r = await gql(M_TRACK, { id: iid }); (r.inventoryItemUpdate?.userErrors || []).forEach(e => errs.push('track:' + e.message));
+    r = await gql(M_ACTIVATE, { iid, loc: LOCATION_ID });
+    (r.inventoryActivate?.userErrors || []).filter(e => !/already/i.test(e.message)).forEach(e => errs.push('activate:' + e.message));
+    const r2 = await gql(M_SETQTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true,
+      quantities: [{ inventoryItemId: iid, locationId: LOCATION_ID, quantity: TARGET_QTY }] } });
+    (r2.inventorySetQuantities?.userErrors || []).forEach(e => errs.push('setqty:' + e.message));
+  } else errs.push('no-inventoryItem-on-sellable');
+  const r3 = await gql(M_PUBLISH, { id: gidP(t.id), pubs: PUBLICATIONS.map(x => ({ publicationId: x })) });
+  (r3.publishablePublish?.userErrors || []).forEach(e => errs.push('publish:' + e.message));
+  const r4 = await gql(M_ACTIVE, { id: gidP(t.id) });
+  (r4.productUpdate?.userErrors || []).forEach(e => errs.push('active:' + e.message));
+  results.push({ ...t, action: r4.productUpdate?.product?.status === 'ACTIVE' ? 'ACTIVATED' : 'INCOMPLETE', status: r4.productUpdate?.product?.status, errs });
+}
+
+fs.writeFileSync(path.join(outDir, 'activate-dalmatians-SNAPSHOT-TK-11046.json'),
+  JSON.stringify({ ticket: 'TK-11046', settlement_override_by: 'Steve 2026-09-02', built_at: new Date().toISOString(), apply: APPLY, snapshots }, null, 2));
+
+if (APPLY) {
+  const now = new Date().toISOString();
+  const lines = results.filter(r => r.action === 'ACTIVATED').map(r => JSON.stringify({
+    ts: now, agent: 'vp-dw-commerce', ticket: 'TK-11046',
+    action: `activate settlement-approved Dalmatian ${r.sku} (${r.handle}) id=${r.id} -> ACTIVE, 12 channels (Google excl), inv 2026 @Ventura on ${SHOP}`,
+    blast_radius: 1,
+    undo_cmd: `productUpdate id=${r.id} status:DRAFT + publishableUnpublish (12 channels); pre-state DRAFT in out/activate-dalmatians-SNAPSHOT-TK-11046.json`,
+    verify: 'product status ACTIVE + published to 12 channels',
+    note: 'settlement REVIEW-VISION gate cleared by Steve 2026-09-02',
+  }));
+  if (lines.length) fs.appendFileSync(ledgerPath, lines.join('\n') + '\n');
+}
+
+console.log(APPLY ? '=== APPLIED ===' : '=== DRY-RUN (pass --apply) ===');
+for (const r of results) console.log(`  ${r.action.padEnd(14)} ${r.sku} ${r.handle}${r.reason ? '  reason=' + r.reason : ''}${r.status ? '  status=' + r.status : ''}${r.errs && r.errs.length ? '  ERRS=' + r.errs.join(';') : ''}`);
diff --git a/scripts/sanderson-onboard/delete-dup-drafts-TK-11046.mjs b/scripts/sanderson-onboard/delete-dup-drafts-TK-11046.mjs
new file mode 100644
index 0000000..f34716c
--- /dev/null
+++ b/scripts/sanderson-onboard/delete-dup-drafts-TK-11046.mjs
@@ -0,0 +1,98 @@
+// TK-11046 — delete the 4 '-1' handle-collision DUPLICATE DRAFT products (Steve-approved 2026-09-02).
+// Rails: snapshot-before-delete (recreatable), per-item pre-assertions (must still be DRAFT + '-1'
+// handle + expected SKU AND its ACTIVE canonical sibling must exist), delete, re-verify siblings.
+// Pass --apply to execute; default is DRY-RUN.
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { gql, SHOP } from '../lib/shopify.mjs';
+
+const APPLY = process.argv.includes('--apply');
+const __dir = path.dirname(fileURLToPath(import.meta.url));
+const outDir = path.join(__dir, 'out');
+
+// EXACT targets — hard-coded. Each: dup to delete + expected fingerprint + its canonical sibling to KEEP.
+const TARGETS = [
+  { id: '7938828533811', sku: 'DWXH-255002', handle: 'adele-rose-cream-sanderson-1',            siblingHandle: 'adele-rose-cream-sanderson',            siblingSku: 'DWXH-600060' },
+  { id: '7939309076531', sku: 'DWXH-255085', handle: 'chelsea-white-pink-sanderson-1',          siblingHandle: 'chelsea-white-pink-sanderson',          siblingSku: 'DWXH-600000' },
+  { id: '7939311075379', sku: 'DWXH-255118', handle: 'dandelion-clocks-red-sanderson-1',        siblingHandle: 'dandelion-clocks-red-sanderson',        siblingSku: 'DWXH-600041' },
+  { id: '7939311435827', sku: 'DWXH-255121', handle: 'dandelion-clocks-chaffinch-sanderson-1',  siblingHandle: 'dandelion-clocks-chaffinch-sanderson',  siblingSku: 'DWXH-600042' },
+];
+
+const gid = id => `gid://shopify/Product/${id}`;
+const snapPath = path.join(outDir, 'delete-dup-drafts-SNAPSHOT-TK-11046.json');
+const ledgerPath = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl');
+
+const snapshots = [];
+const results = [];
+
+for (const t of TARGETS) {
+  // 1. Load the FULL product to snapshot + assert its fingerprint.
+  const q = `query($id:ID!){ product(id:$id){ id status handle title vendor
+    variants(first:10){edges{node{ id sku title price inventoryQuantity }}}
+    media(first:20){edges{node{ ... on MediaImage { image{ url } } }}}
+  } }`;
+  const d = await gql(q, { id: gid(t.id) });
+  const p = d?.product;
+  if (!p) { results.push({ ...t, action: 'SKIP', reason: 'product not found (already deleted?)' }); continue; }
+
+  const skus = (p.variants?.edges || []).map(e => e.node.sku);
+  // 2. PRE-ASSERTIONS — refuse unless it is exactly the dup we expect.
+  const checks = {
+    isDraft: p.status === 'DRAFT',
+    dashOneHandle: p.handle === t.handle && p.handle.endsWith('-1'),
+    skuMatch: skus.includes(t.sku),
+    notCanonical: !skus.includes(t.siblingSku),   // must NOT be the 6000xx canonical
+  };
+  const failed = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k);
+  if (failed.length) { results.push({ ...t, action: 'SKIP', reason: 'assertion failed: ' + failed.join(','), status: p.status, skus }); continue; }
+
+  // 3. Sibling must exist AND be ACTIVE (never orphan a colorway).
+  const sq = `query($q:String!){ products(first:5, query:$q){edges{node{ id handle status variants(first:3){edges{node{sku}}} }}} }`;
+  const sd = await gql(sq, { q: `handle:${t.siblingHandle}` });
+  const sib = (sd?.products?.edges || []).map(e => e.node)
+    .find(n => n.handle === t.siblingHandle && (n.variants?.edges || []).some(v => v.node.sku === t.siblingSku));
+  if (!sib || sib.status !== 'ACTIVE') {
+    results.push({ ...t, action: 'SKIP', reason: `canonical sibling ${t.siblingSku} not ACTIVE (found=${sib ? sib.status : 'none'})` });
+    continue;
+  }
+
+  snapshots.push({ target: t, snapshot: p, sibling: { id: sib.id.split('/').pop(), handle: sib.handle, status: sib.status } });
+
+  if (!APPLY) { results.push({ ...t, action: 'WOULD-DELETE', status: p.status, skus, sibling: `${t.siblingSku} ACTIVE ✓` }); continue; }
+
+  // 4. DELETE.
+  const md = await gql(`mutation($in:ProductDeleteInput!){ productDelete(input:$in){ deletedProductId userErrors{field message} } }`, { in: { id: gid(t.id) } });
+  const err = md?.productDelete?.userErrors;
+  if (err && err.length) { results.push({ ...t, action: 'ERROR', reason: JSON.stringify(err) }); continue; }
+  results.push({ ...t, action: 'DELETED', deletedProductId: md?.productDelete?.deletedProductId, sibling: `${t.siblingSku} ACTIVE ✓ (kept)` });
+}
+
+// Persist snapshot (recreatable record) always.
+fs.writeFileSync(snapPath, JSON.stringify({ ticket: 'TK-11046', built_at: new Date().toISOString(), apply: APPLY, snapshots }, null, 2));
+
+// 5. Post-delete re-verify canonical siblings still ACTIVE (only when applied).
+let siblingCheck = [];
+if (APPLY) {
+  for (const t of TARGETS) {
+    const sd = await gql(`query($q:String!){ products(first:5, query:$q){edges{node{ handle status variants(first:3){edges{node{sku}}} }}} }`, { q: `handle:${t.siblingHandle}` });
+    const sib = (sd?.products?.edges || []).map(e => e.node).find(n => n.handle === t.siblingHandle);
+    siblingCheck.push({ siblingSku: t.siblingSku, handle: t.siblingHandle, status: sib ? sib.status : 'MISSING' });
+  }
+  // 6. Ledger each successful delete.
+  const now = new Date().toISOString();
+  const lines = results.filter(r => r.action === 'DELETED').map(r => JSON.stringify({
+    ts: now, agent: 'vp-dw-commerce', ticket: 'TK-11046',
+    action: `productDelete duplicate DRAFT ${r.sku} (${r.handle}) id=${r.id} on ${SHOP}`,
+    blast_radius: 1,
+    undo_cmd: `recreate from out/delete-dup-drafts-SNAPSHOT-TK-11046.json (or payloads-imgfixed.jsonl row ${r.sku}); canonical sibling ${r.siblingSku} remains live`,
+    verify: `canonical sibling ${r.siblingSku} still ACTIVE`,
+  }));
+  if (lines.length) fs.appendFileSync(ledgerPath, lines.join('\n') + '\n');
+}
+
+console.log(APPLY ? '=== APPLIED ===' : '=== DRY-RUN (pass --apply to execute) ===');
+for (const r of results) console.log(`  ${r.action.padEnd(13)} ${r.sku} ${r.handle}${r.reason ? '  reason=' + r.reason : ''}${r.deletedProductId ? '  ' + r.deletedProductId : ''}`);
+if (siblingCheck.length) { console.log('\nCanonical siblings after delete (must all be ACTIVE):'); siblingCheck.forEach(s => console.log(`  ${s.status.padEnd(8)} ${s.siblingSku} ${s.handle}`)); }
+console.log(`\nsnapshot: ${snapPath}`);
+if (APPLY) console.log(`ledgered ${results.filter(r => r.action === 'DELETED').length} delete(s) -> ${ledgerPath}`);
diff --git a/scripts/sanderson-onboard/rollback-map-build.mjs b/scripts/sanderson-onboard/rollback-map-build.mjs
new file mode 100644
index 0000000..4f05742
--- /dev/null
+++ b/scripts/sanderson-onboard/rollback-map-build.mjs
@@ -0,0 +1,71 @@
+// TK-11046 — Sanderson go-live rollback map + live-status verification (READ-ONLY).
+// Reads out/golive-done.jsonl (302 unique product_ids that golive set ACTIVE),
+// batch-queries Shopify for each product's TRUE current status/handle/title,
+// and writes a rollback map: product_id -> {sku, current_status, handle, revert}.
+// No writes to Shopify. The map is the safety artifact required BEFORE any publish write.
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { gql, SHOP } from '../lib/shopify.mjs';
+
+const __dir = path.dirname(fileURLToPath(import.meta.url));
+const outDir = path.join(__dir, 'out');
+const doneLines = fs.readFileSync(path.join(outDir, 'golive-done.jsonl'), 'utf8')
+  .trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
+
+// dedup by product_id, keep first sku seen
+const byId = new Map();
+for (const r of doneLines) if (!byId.has(r.product_id)) byId.set(r.product_id, r.sku);
+const ids = [...byId.keys()];
+console.log(`Building rollback map for ${ids.length} unique product_ids from golive-done.jsonl`);
+
+const gid = id => id.startsWith('gid://') ? id : `gid://shopify/Product/${id}`;
+const chunk = (a, n) => a.reduce((o, _, i) => (i % n ? o : [...o, a.slice(i, i + n)]), []);
+
+const rows = [];
+const statusCount = {};
+for (const [bi, batch] of chunk(ids, 50).entries()) {
+  const q = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id status handle title } } }`;
+  const d = await gql(q, { ids: batch.map(gid) });
+  if (d?.__err) { console.error('GQL error on batch', bi, JSON.stringify(d.__err)); continue; }
+  for (const n of (d?.nodes || [])) {
+    if (!n) continue;
+    const numId = n.id.split('/').pop();
+    const sku = byId.get(numId) || byId.get(n.id) || null;
+    statusCount[n.status] = (statusCount[n.status] || 0) + 1;
+    rows.push({
+      product_id: numId,
+      sku,
+      current_status: n.status,          // expected ACTIVE (set by golive)
+      pre_golive_status: 'DRAFT',        // golive activated from DRAFT
+      handle: n.handle,
+      pdp: `https://${SHOP}/admin/products/${numId}`,
+      revert: 'productUpdate status:DRAFT + publishableUnpublish (12 channels)',
+    });
+  }
+  process.stdout.write(`  batch ${bi + 1}: cumulative ${rows.length}\r`);
+}
+console.log('');
+
+// detect any id that Shopify did not return (deleted / not found)
+const returned = new Set(rows.map(r => r.product_id));
+const missing = ids.filter(i => !returned.has(i));
+
+const map = {
+  ticket: 'TK-11046',
+  purpose: 'Sanderson go-live rollback map — product_id -> current status. Revert = set DRAFT + unpublish.',
+  store: SHOP,
+  built_at: new Date().toISOString(),
+  total_ids: ids.length,
+  status_distribution: statusCount,
+  missing_from_shopify: missing,
+  products: rows,
+};
+const outPath = path.join(outDir, 'rollback-map-TK-11046.json');
+fs.writeFileSync(outPath, JSON.stringify(map, null, 2));
+console.log(`\nWROTE ${outPath}`);
+console.log(`status distribution: ${JSON.stringify(statusCount)}`);
+console.log(`missing from Shopify (deleted/not found): ${missing.length}${missing.length ? ' -> ' + missing.slice(0,10).join(',') : ''}`);
+console.log('\nSpot-check PDP admin URLs (first 5 live):');
+rows.filter(r => r.current_status === 'ACTIVE').slice(0, 5)
+  .forEach(r => console.log(`  ${r.sku}  ${r.handle}  -> ${r.pdp}`));

← c9a5901 TK-10209: publish 435 O&L products missing from Google/YouTu  ·  back to Designerwallcoverings  ·  TK-11061: reprice ~457 real-roll variants with leaked $4.25 3733067 →