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