← back to Mfr Recovery 2026 08 23

push-mfr-to-shopify.mjs

132 lines

#!/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.)
//
// JOIN-KEY FIX (TK-10827, 2026-08-27): the previous version joined
// `nc.mfr_sku = stripped_handle`, which recovered 0 rows — the catalog's
// mfr_sku is a REAL vendor T-code (e.g. T3D8W), never the color slug, so it
// can't equal a stripped handle. The correct join is `nc.sku = handle`
// (the catalog `sku` column IS the color-slug handle, e.g. 'novasuede™-sand'),
// then read the real code from `nc.mfr_sku`. We also HARD-EXCLUDE any placeholder
// code that is itself a slug (`^novasuede`) so a stripped-handle can never be
// pushed as a manufacturer_sku (worse than leaving blank — the prior HOLD reason).
// With Gate-1 staging reconciled to authoritative novasuede.com codes, this
// recovers exactly 1: novasuede™-sand -> T3D8W.
function loadNovasuede() {
  const sql = `
    WITH nulls AS (
      SELECT handle
      FROM shopify_products
      WHERE vendor='Novasuede' AND status='ACTIVE' AND (mfr_sku IS NULL OR mfr_sku='')
    )
    SELECT n.handle || '|' || nc.mfr_sku
    FROM nulls n JOIN novasuede_catalog nc ON nc.sku = n.handle
    WHERE nc.mfr_sku IS NOT NULL AND nc.mfr_sku <> '' AND nc.mfr_sku !~ '^novasuede';`;
  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); });