[object Object]

← back to Mfr Recovery 2026 08 23

TK-10677: add push-mfr-to-shopify.mjs (dry-run default; sets custom.+dwc.manufacturer_sku; restore-map; reversible)

31981a9cf64b36eb8586df311cd603fb0625d348 · 2026-08-24 21:21:09 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 31981a9cf64b36eb8586df311cd603fb0625d348
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 24 21:21:09 2026 -0700

    TK-10677: add push-mfr-to-shopify.mjs (dry-run default; sets custom.+dwc.manufacturer_sku; restore-map; reversible)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 push-mfr-to-shopify.mjs | 119 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 119 insertions(+)

diff --git a/push-mfr-to-shopify.mjs b/push-mfr-to-shopify.mjs
new file mode 100644
index 0000000..5de1301
--- /dev/null
+++ b/push-mfr-to-shopify.mjs
@@ -0,0 +1,119 @@
+#!/usr/bin/env node
+/**
+ * push-mfr-to-shopify.mjs  —  TK-10677 durable mfr_sku recovery, Shopify side.
+ *
+ * Reads the recovered {handle -> mfr_sku} maps for Zoffany (24, from the verified CSV)
+ * and Novasuede (recoverable set, derived live from dw_unified), resolves each handle
+ * to its live Product GID, and SETS the manufacturer-SKU metafields:
+ *      custom.manufacturer_sku   (single_line_text_field)
+ *      dwc.manufacturer_sku      (single_line_text_field)
+ * — the canonical DW mfr-metafield pattern (see knoll-onboard/build-payloads.mjs metafields()).
+ *
+ * DEFAULT is --dry-run: resolves handles, prints the plan, sets NOTHING.
+ *   node push-mfr-to-shopify.mjs                     # DRY-RUN (default): resolve + plan
+ *   node push-mfr-to-shopify.mjs --vendor=zoffany    # scope to one vendor (zoffany|novasuede|all)
+ *   node push-mfr-to-shopify.mjs --apply             # GATED: actually write metafields
+ *
+ * Idempotent + reversible: on --apply it records a restore-map (ownerId, ns.key, old->new)
+ * to out/mfr-push-restore-<ts>.jsonl so every write can be reverted. Never touches variants,
+ * price, status, or images — metafields only.
+ *
+ * HARD RAIL: this is a customer-facing Shopify write. Do NOT run --apply without Steve's
+ * approval (see pending-approval/2026-08-25-TK-10677-durable-recovery-RUNBOOK.md).
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+import { gql, SHOP, VER, TOKEN } from '../../Projects/designerwallcoverings/scripts/lib/shopify.mjs';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+fs.mkdirSync(OUT, { recursive: true });
+
+const APPLY = process.argv.includes('--apply');
+const vendorArg = (process.argv.find(a => a.startsWith('--vendor=')) || '--vendor=all').split('=')[1].toLowerCase();
+
+const NS = [
+  { namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field' },
+  { namespace: 'dwc',    key: 'manufacturer_sku', type: 'single_line_text_field' },
+];
+
+// ── recovered maps ────────────────────────────────────────────────────────────
+// Zoffany: from the verified CSV (24 real ZxxxNNNNNN mfr codes).
+function loadZoffany() {
+  const csv = path.join(HERE, 'zoffany_24_verified_20260824.csv');
+  if (!fs.existsSync(csv)) return [];
+  const [head, ...rows] = fs.readFileSync(csv, 'utf8').trim().split('\n');
+  const cols = head.split(',');
+  const hi = cols.indexOf('handle'), mi = cols.indexOf('mfr_sku');
+  return rows.map(l => l.split(',')).map(c => ({ vendor: 'Zoffany', handle: c[hi], mfr_sku: c[mi] }))
+    .filter(r => r.handle && r.mfr_sku);
+}
+
+// Novasuede: derive live from dw_unified so we never push stale rows.
+// (The old SQL claimed 14; current truth = 1 recoverable. Derive, don't hardcode.)
+function loadNovasuede() {
+  const sql = `
+    WITH stripped AS (
+      SELECT handle, regexp_replace(handle, '-luxury-suede.*|(-fabric-wallcovering)', '', 'g') AS sh
+      FROM shopify_products
+      WHERE vendor='Novasuede' AND status='ACTIVE' AND (mfr_sku IS NULL OR mfr_sku='')
+    )
+    SELECT s.handle || '|' || nc.mfr_sku
+    FROM stripped s JOIN novasuede_catalog nc ON nc.mfr_sku = s.sh;`;
+  let out;
+  try {
+    out = execFileSync('psql', ['-h', '/tmp', '-d', 'dw_unified', '-tA', '-c', sql], { encoding: 'utf8' });
+  } catch { return []; }
+  return out.trim().split('\n').filter(Boolean).map(l => {
+    const [handle, mfr_sku] = l.split('|');
+    return { vendor: 'Novasuede', handle, mfr_sku };
+  });
+}
+
+async function productByHandle(handle) {
+  const q = `query($h:String!){ productByHandle(handle:$h){ id title status
+      metafields(first:50){ nodes { namespace key value } } } }`;
+  const d = await gql(q, { h: handle });
+  return d?.productByHandle || null;
+}
+
+async function main() {
+  let items = [];
+  if (vendorArg === 'all' || vendorArg === 'zoffany')   items = items.concat(loadZoffany());
+  if (vendorArg === 'all' || vendorArg === 'novasuede') items = items.concat(loadNovasuede());
+
+  console.log(`Store ${SHOP} · API ${VER} · token …${TOKEN.slice(-4)} · scope=${vendorArg} · ${items.length} recovered rows · ${APPLY ? 'APPLY' : 'DRY-RUN (default)'}`);
+  if (!items.length) { console.log('No recovered rows for this scope. Nothing to do.'); return; }
+
+  const restoreFd = APPLY ? fs.openSync(path.join(OUT, `mfr-push-restore-${Date.now()}.jsonl`), 'a') : null;
+  let planned = 0, wrote = 0, skipped = 0, notFound = 0, errs = 0;
+
+  for (const it of items) {
+    const p = await productByHandle(it.handle);
+    if (!p) { console.error(`  ✗ NOT-FOUND  ${it.vendor}  ${it.handle}`); notFound++; continue; }
+    const existing = Object.fromEntries((p.metafields?.nodes || []).map(m => [`${m.namespace}.${m.key}`, m.value]));
+    // already-correct?
+    const already = NS.every(t => existing[`${t.namespace}.${t.key}`] === it.mfr_sku);
+    if (already) { console.log(`  = SKIP(set)  ${it.handle}  ${it.mfr_sku}`); skipped++; continue; }
+
+    console.log(`  ${APPLY ? '→ SET' : '· PLAN'}   ${it.handle}  ${it.mfr_sku}  (was custom=${existing['custom.manufacturer_sku'] ?? '∅'} dwc=${existing['dwc.manufacturer_sku'] ?? '∅'})`);
+    planned++;
+    if (!APPLY) continue;
+
+    const mf = NS.map(t => ({ ownerId: p.id, namespace: t.namespace, key: t.key, type: t.type, value: it.mfr_sku }));
+    const r = await gql(`mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ userErrors{ field message } } }`, { m: mf });
+    const ue = r?.metafieldsSet?.userErrors || r?.__err || [];
+    if (ue.length) { console.error(`    ⚠ error ${it.handle}: ${JSON.stringify(ue).slice(0,180)}`); errs++; continue; }
+    fs.writeSync(restoreFd, JSON.stringify({
+      ts: new Date().toISOString(), ownerId: p.id, handle: it.handle, vendor: it.vendor,
+      set: NS.map(t => ({ ns: t.namespace, key: t.key, old: existing[`${t.namespace}.${t.key}`] ?? null, new: it.mfr_sku })),
+    }) + '\n');
+    wrote++;
+  }
+  if (restoreFd) fs.closeSync(restoreFd);
+  console.log(`\nDone. planned=${planned} wrote=${wrote} skipped=${skipped} not-found=${notFound} errors=${errs}${APPLY ? ` · restore-map in out/` : ' · DRY-RUN — add --apply (GATED) to write'}`);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });

← fc2116f TK-10677: verify 24 Zoffany recoverable (/bin/zsh join), mar  ·  back to Mfr Recovery 2026 08 23  ·  TK-10827: durable two-surface mfr push (Kamatera DB + Shopif d390b67 →