← back to Filemaker Mcp

grs-mfr-apply.mjs

115 lines

#!/usr/bin/env node
// GRS mfr fill: FileMaker-recovered real codes -> Shopify (dwc + custom manufacturer_sku) + mirror column.
// REVERSIBLE: writes a restore record BEFORE every change.
// GUARD: only writes when current mfr is EMPTY or the PLACEHOLDER (=DW number / DW sku).
//        NEVER clobbers an existing different real code.
// Modes:  --only <DW-SKU>   single (canary)
//         --dry             report only (default)
//         --apply           live writes
import fs from 'fs';
import https from 'https';
import { execSync } from 'child_process';
import path from 'path';

for (const l of fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8').split('\n')) {
  const m = l.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
}
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const PSQL = 'psql -h /tmp -d dw_unified -tAc';
const args = process.argv.slice(2);
const APPLY = args.includes('--apply');
const ONLY = (args[args.indexOf('--only') + 1] && args.includes('--only')) ? args[args.indexOf('--only') + 1].toUpperCase() : null;
const Q = String.fromCharCode(34);
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const RESTORE = process.env.HOME + `/Desktop/GRS_mfr_RESTORE_${stamp}.jsonl`;
const LEDGER = process.env.HOME + '/.claude/yolo-queue/executed-reversible/ledger.jsonl';

function parseCSV(t) { const R = []; let i = 0, f = '', r = [], q = false; while (i < t.length) { const c = t[i]; if (q) { if (c === Q) { if (t[i + 1] === Q) { f += Q; i++; } else q = false; } else f += c; } else { if (c === Q) q = true; else if (c === ',') { r.push(f); f = ''; } else if (c === '\n') { r.push(f); R.push(r); r = []; f = ''; } else if (c !== '\r') f += c; } i++; } if (f.length || r.length) { r.push(f); R.push(r); } return R; }
const norm = s => (s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
function gql(query, variables) {
  return new Promise((res, rej) => { const b = JSON.stringify({ query, variables });
    const r = https.request({ host: DOMAIN, path: '/admin/api/2024-10/graphql.json', method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(b) } },
      x => { let d = ''; x.on('data', c => d += c); x.on('end', () => { try { res(JSON.parse(d)); } catch (e) { rej(new Error(d)); } }); });
    r.on('error', rej); r.write(b); r.end(); });
}
const sqlesc = s => String(s).replace(/'/g, "''");

// ---- load fill map (REAL only) ----
const fm = parseCSV(fs.readFileSync(process.env.HOME + `/Desktop/GRS_mfr_fill_map_${stamp}.csv`, 'utf8'));
const fh = fm.shift(); const fi = Object.fromEntries(fh.map((x, i) => [x, i]));
const code = new Map(); // DW-SKU -> clean_mfr
for (const r of fm) { if (r[fi.status] === 'REAL' && r[fi.clean_mfr]) code.set(r[fi.dw_sku].toUpperCase(), r[fi.clean_mfr]); }

// ---- load GRS FM dup-master counts (DTD-C: skip any dw_sku with >1 FileMaker master) ----
const fmAll = parseCSV(fs.readFileSync(process.env.HOME + `/Desktop/GRS_fm_all_records_${stamp}.csv`, 'utf8'));
const ah = fmAll.shift(); const ai = Object.fromEntries(ah.map((x, i) => [x, i]));
// extract a real code token from a free-text Mfr Pattern (mirrors build-grs-map classify)
function extractCode(raw, tail) {
  const up = (raw || '').toUpperCase().trim();
  if (!up || /DO NOT SELL|CONFUSING|TEST RECORD|DELETE|DISCONTINUE|NEED SAMPLE/.test(up)) return null;
  const m = up.match(/\b([A-Z]{1,4}\d{1,6}[A-Z]?)\b/);
  if (m) { const c = m[1]; return (c === tail) ? null : c; }
  return null;
}
// per dw_sku: the SET of distinct real codes across ALL its FileMaker masters.
// >1 distinct => masters DISAGREE => skip (real ambiguity). 1 distinct => harmless dup => write.
const codesByBase = new Map();
for (const r of fmAll) {
  if (!r.length) continue;
  const c = norm(r[ai.combo_sku]); if (!c) continue;
  const tail = norm((r[ai.js_pattern] || ''));
  const code = extractCode(r[ai.mfr_pattern], tail);
  if (!codesByBase.has(c)) codesByBase.set(c, new Set());
  if (code) codesByBase.get(c).add(code);
}

// ---- load mirror ----
const mir = parseCSV(fs.readFileSync('/tmp/grs_mirror.csv', 'utf8'));
const mh = mir.shift(); const mi = Object.fromEntries(mh.map((x, i) => [x, i]));

let plan = [], skipNoCode = 0, skipHasReal = 0, skipSame = 0, skipNotActive = 0, skipDirtyMaster = 0;
for (const r of mir) {
  if (!r.length || !r[mi.product_id]) continue;
  const dwsku = (r[mi.sku] || '').replace(/-sample$/i, '').toUpperCase();  // GRS-43023
  if (ONLY && dwsku !== ONLY) continue;
  const want = code.get(dwsku);
  if (!want) { skipNoCode++; continue; }
  const tail = norm(dwsku.replace(/^GRS-?/, ''));         // 43023
  const cur = r[mi.dwc_mfr] || r[mi.custom_mfr] || r[mi.mirror_mfr] || '';
  const curN = norm(cur);
  const isPlaceholder = curN === '' || curN === tail || curN === norm(dwsku) || curN === 'GRS' + tail;
  if (norm(want) === curN) { skipSame++; continue; }        // already correct
  if (!isPlaceholder) { skipHasReal++; console.error(`  SKIP-HAS-REAL ${dwsku}: current=${JSON.stringify(cur)} (not clobbering; want ${want})`); continue; }
  const distinct = codesByBase.get(norm(dwsku)); // DTD-C: skip only if masters DISAGREE on the code
  if (distinct && distinct.size > 1) { skipDirtyMaster++; console.error(`  SKIP-CONFLICT ${dwsku}: masters disagree ${JSON.stringify([...distinct])}`); continue; }
  plan.push({ product_id: r[mi.product_id], dwsku, want, old_dwc: r[mi.dwc_mfr], old_custom: r[mi.custom_mfr], old_mirror: r[mi.mirror_mfr] });
}

console.log(`\n=== GRS mfr apply (${APPLY ? 'LIVE' : 'DRY-RUN'})${ONLY ? ' only=' + ONLY : ''} ===`);
console.log(`planned writes: ${plan.length}`);
console.log(`skips -> no-code:${skipNoCode}  already-correct:${skipSame}  has-real(protected):${skipHasReal}  dirty-master(await dedupe):${skipDirtyMaster}`);
if (plan.length <= 20 || ONLY) for (const p of plan) console.log(`  ${p.dwsku}  ${JSON.stringify(p.old_dwc || p.old_mirror || '')} -> ${p.want}   (product ${p.product_id})`);

if (!APPLY) { console.log(`\n[dry-run] no writes. Re-run with --apply to fire.\n`); process.exit(0); }

// ---- LIVE ----
let done = 0, errs = 0;
for (const p of plan) {
  const gid = `gid://shopify/Product/${p.product_id}`;
  // restore record BEFORE write
  fs.appendFileSync(RESTORE, JSON.stringify({ ts: new Date().toISOString(), product_id: p.product_id, dwsku: p.dwsku, old_dwc: p.old_dwc, old_custom: p.old_custom, old_mirror: p.old_mirror, new_mfr: p.want }) + '\n');
  const res = await gql(`mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ field message } } }`,
    { mf: [
      { ownerId: gid, namespace: 'dwc', key: 'manufacturer_sku', type: 'single_line_text_field', value: p.want },
      { ownerId: gid, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: p.want },
    ] });
  const ue = res?.data?.metafieldsSet?.userErrors;
  if (ue && ue.length) { errs++; console.error(`  ERR ${p.dwsku}: ${JSON.stringify(ue)}`); continue; }
  execSync(`${PSQL} "UPDATE shopify_products SET mfr_sku='${sqlesc(p.want)}' WHERE regexp_replace(shopify_id,'.*/','')='${p.product_id}'"`);
  done++;
  if (done % 25 === 0) console.log(`  ...${done}/${plan.length}`);
}
fs.appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), agent: 'claude-grs-mfr', ticket: 'GRS-mfr-recovery', action: `GRS mfr FMP->Shopify fill (${done} products)`, blast_radius: done, undo_cmd: `node grs-mfr-restore.mjs ${RESTORE}`, verify: 'read dwc.manufacturer_sku' }) + '\n');
console.log(`\nLIVE done: ${done} written, ${errs} errors. Restore map -> ${RESTORE}\n`);