← 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); });