← back to Filemaker Mcp
GRS mfr recovery: FMP->Shopify fill (425 written, reversible) + dedup plan + last-1000 audit
0b289845129987ee8762635654f5cdcebe20d993 · 2026-08-19 11:20:27 -0700 · steve
Files touched
A audit-fm-last.mjsA build-grs-map.mjsA grs-dedup-delete.mjsA grs-mfr-apply.mjsA grs-mfr-restore.mjsA plan-grs-dedup.mjsA pull-fm-last1000.mjsA pull-grs-mfr.mjsA scan-grs-wrong-mfr.mjs
Diff
commit 0b289845129987ee8762635654f5cdcebe20d993
Author: steve <steve@designerwallcoverings.com>
Date: Wed Aug 19 11:20:27 2026 -0700
GRS mfr recovery: FMP->Shopify fill (425 written, reversible) + dedup plan + last-1000 audit
---
audit-fm-last.mjs | 43 +++++++++++++++++++
build-grs-map.mjs | 98 ++++++++++++++++++++++++++++++++++++++++++
grs-dedup-delete.mjs | 31 ++++++++++++++
grs-mfr-apply.mjs | 114 +++++++++++++++++++++++++++++++++++++++++++++++++
grs-mfr-restore.mjs | 25 +++++++++++
plan-grs-dedup.mjs | 42 ++++++++++++++++++
pull-fm-last1000.mjs | 34 +++++++++++++++
pull-grs-mfr.mjs | 47 ++++++++++++++++++++
scan-grs-wrong-mfr.mjs | 36 ++++++++++++++++
9 files changed, 470 insertions(+)
diff --git a/audit-fm-last.mjs b/audit-fm-last.mjs
new file mode 100644
index 0000000..ec98916
--- /dev/null
+++ b/audit-fm-last.mjs
@@ -0,0 +1,43 @@
+import fs from 'fs';
+const Q = String.fromCharCode(34);
+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 raw = parseCSV(fs.readFileSync(process.argv[2], 'utf8'));
+const h = raw.shift(); const ix = Object.fromEntries(h.map((x, i) => [x, i]));
+const norm = s => (s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+const recs = raw.filter(r => r.length && r[ix.combo_sku] !== undefined);
+
+const bySeries = {}, byDate = {}, bySupplier = {};
+let mfrIsDwsku = [], mfrBlank = 0, seriesBlank = 0, dupCombo = {};
+for (const r of recs) {
+ const combo = norm(r[ix.combo_sku]);
+ const num = norm(r[ix.js_pattern]);
+ const mfr = norm(r[ix.mfr_pattern]);
+ const series = r[ix.series] || '(blank)';
+ bySeries[series] = (bySeries[series] || 0) + 1;
+ const d = (r[ix.date_put_up] || '').split(' ')[0] || '(blank)';
+ byDate[d] = (byDate[d] || 0) + 1;
+ bySupplier[r[ix.supplier] || '(blank)'] = (bySupplier[r[ix.supplier] || '(blank)'] || 0) + 1;
+ if (!mfr) mfrBlank++;
+ else if (mfr === num || mfr === combo || mfr === 'GRS' + num || mfr === series.toUpperCase().replace(/[^A-Z0-9]/g, '') + num) mfrIsDwsku.push(r);
+ if (!r[ix.series]) seriesBlank++;
+ dupCombo[combo] = (dupCombo[combo] || 0) + 1;
+}
+const dups = Object.entries(dupCombo).filter(([k, v]) => v > 1 && k).sort((a, b) => b[1] - a[1]);
+const top = (o, n = 12) => Object.entries(o).sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => ` ${String(v).padStart(4)} ${k}`).join('\n');
+
+console.log('=== AUDIT: last', recs.length, 'FM records (newest by internal order) ===\n');
+console.log('BY SERIES:\n' + top(bySeries));
+console.log('\nBY DATE PUT UP:\n' + top(byDate));
+console.log('\nBY SUPPLIER:\n' + top(bySupplier));
+console.log('\n--- PROBLEMS ---');
+console.log('Mfr field = DW SKU (WRONG):', mfrIsDwsku.length);
+console.log('Mfr field BLANK:', mfrBlank);
+console.log('Series BLANK:', seriesBlank);
+console.log('Duplicate combo_sku within last 1000:', dups.length, dups.length ? '(top: ' + dups.slice(0, 8).map(d => d[0] + '×' + d[1]).join(', ') + ')' : '');
+console.log('\n--- sample Mfr=DW-SKU wrong rows (combo | mfr | series | supplier | recId) ---');
+for (const r of mfrIsDwsku.slice(0, 20)) console.log(' ' + r[ix.combo_sku] + ' | ' + JSON.stringify(r[ix.mfr_pattern]) + ' | ' + r[ix.series] + ' | ' + JSON.stringify(r[ix.supplier]) + ' | ' + r[ix.record_id]);
+
+const out = process.env.HOME + '/Desktop/FM_last1000_WRONG_mfr_20260819.csv';
+const qq = x => Q + String(x ?? '').replace(/"/g, Q + Q) + Q;
+fs.writeFileSync(out, h.join(',') + '\n' + mfrIsDwsku.map(r => r.map(qq).join(',')).join('\n') + '\n');
+console.log('\nwrote wrong-mfr rows ->', out);
diff --git a/build-grs-map.mjs b/build-grs-map.mjs
new file mode 100644
index 0000000..7065df3
--- /dev/null
+++ b/build-grs-map.mjs
@@ -0,0 +1,98 @@
+// Build a clean GRS dw_sku -> real mfr code map from the FM dump.
+// Read-only. Produces a reviewed fill map + a junk/needs-manual list.
+import fs from 'fs';
+const SRC = process.argv[2];
+const OUT = process.env.HOME + '/Desktop/GRS_mfr_fill_map_' +
+ new Date().toISOString().slice(0, 10).replace(/-/g, '') + '.csv';
+
+// --- tiny CSV parser (quoted) ---
+function parseCSV(txt) {
+ const rows = []; let i = 0, f = '', row = [], q = false;
+ while (i < txt.length) {
+ const c = txt[i];
+ if (q) { if (c === '"') { if (txt[i + 1] === '"') { f += '"'; i++; } else q = false; } else f += c; }
+ else { if (c === '"') q = true; else if (c === ',') { row.push(f); f = ''; } else if (c === '\n') { row.push(f); rows.push(row); row = []; f = ''; } else if (c !== '\r') f += c; }
+ i++;
+ }
+ if (f.length || row.length) { row.push(f); rows.push(row); }
+ return rows;
+}
+
+const raw = parseCSV(fs.readFileSync(SRC, 'utf8'));
+const hdr = raw.shift();
+const idx = Object.fromEntries(hdr.map((h, i) => [h, i]));
+
+// Extract a real mfr code token from a free-text Mfr Pattern.
+// Returns {code, kind} where kind = REAL | PLACEHOLDER | JUNK | BLANK
+function classify(mfrRaw, jsPattern) {
+ const s = (mfrRaw || '').trim();
+ if (!s) return { code: '', kind: 'BLANK' };
+ const up = s.toUpperCase();
+ if (/DO NOT SELL|CONFUSING|TEST RECORD|DELETE|DISCONTINUE|SEE |NEED SAMPLE/i.test(s)) return { code: '', kind: 'JUNK' };
+ // grab leading code-like token: letters then digits (e.g. SC5823, GT3940, SUA211, A4, BL72600)
+ const m = up.match(/\b([A-Z]{1,4}\d{1,6}[A-Z]?)\b/);
+ if (m) {
+ const code = m[1];
+ if (code === String(jsPattern || '').toUpperCase()) return { code: '', kind: 'PLACEHOLDER' };
+ return { code, kind: 'REAL' };
+ }
+ // pure-number mfr that differs from js pattern -> possible real numeric code, flag as REVIEW
+ const n = up.match(/^(\d{2,6})\b/);
+ if (n) {
+ if (n[1] === String(jsPattern || '')) return { code: '', kind: 'PLACEHOLDER' };
+ return { code: n[1], kind: 'REVIEW_NUM' };
+ }
+ return { code: '', kind: 'JUNK' };
+}
+
+const norm = s => (s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+// group all FM records by dw base sku
+const byBase = new Map();
+for (const r of raw) {
+ if (!r.length || !r[idx.combo_sku]) continue;
+ const combo = norm(r[idx.combo_sku]); // GRS43023
+ if (!combo.startsWith('GRS')) continue;
+ const base = 'GRS-' + combo.slice(3); // GRS-43023
+ const rec = {
+ base, mfrRaw: r[idx.mfr_pattern], js: r[idx.js_pattern],
+ supplier: r[idx.supplier], net: r[idx.net_price],
+ jpg: r[idx.jpg_name], recId: r[idx.fm_record_id]
+ };
+ rec.cls = classify(rec.mfrRaw, rec.js);
+ if (!byBase.has(base)) byBase.set(base, []);
+ byBase.get(base).push(rec);
+}
+
+// pick best record per base: prefer REAL > REVIEW_NUM > PLACEHOLDER > JUNK > BLANK
+const rank = { REAL: 5, REVIEW_NUM: 4, PLACEHOLDER: 2, JUNK: 1, BLANK: 0 };
+const out = [];
+const counts = {};
+for (const [base, recs] of byBase) {
+ recs.sort((a, b) => rank[b.cls.kind] - rank[a.cls.kind]);
+ const best = recs[0];
+ const status = best.cls.kind;
+ counts[status] = (counts[status] || 0) + 1;
+ // pick a supplier from any record that has a non-DW one
+ const supp = (recs.find(r => r.supplier && !/designer wallcoverings/i.test(r.supplier))?.supplier)
+ || best.supplier || '';
+ out.push({
+ dw_sku: base, clean_mfr: best.cls.code, status,
+ mfr_raw: (best.mfrRaw || '').trim(), supplier: supp,
+ net: best.net || '', dup_records: recs.length
+ });
+}
+out.sort((a, b) => a.dw_sku.localeCompare(b.dw_sku, undefined, { numeric: true }));
+
+const csvq = s => '"' + String(s ?? '').replace(/"/g, '""') + '"';
+const cols = ['dw_sku', 'clean_mfr', 'status', 'mfr_raw', 'supplier', 'net', 'dup_records'];
+fs.writeFileSync(OUT, cols.join(',') + '\n' + out.map(o => cols.map(c => csvq(o[c])).join(',')).join('\n') + '\n');
+
+console.log('== GRS mfr map built ==');
+console.log('unique GRS base skus in FM:', out.length);
+console.log('status counts:', JSON.stringify(counts, null, 0));
+console.log('REAL codes recovered:', out.filter(o => o.status === 'REAL').length);
+console.log('out ->', OUT);
+console.log('\n-- GRS-43023 check --');
+console.log(JSON.stringify(out.find(o => o.dw_sku === 'GRS-43023')));
+console.log('\n-- sample REAL codes --');
+for (const o of out.filter(o => o.status === 'REAL').slice(0, 12)) console.log(`${o.dw_sku} -> ${o.clean_mfr} [${o.mfr_raw}]`);
diff --git a/grs-dedup-delete.mjs b/grs-dedup-delete.mjs
new file mode 100644
index 0000000..4cea7ab
--- /dev/null
+++ b/grs-dedup-delete.mjs
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+// REVERSIBLE FileMaker dedup: delete the EXTRA duplicate GRS masters listed in the dedup plan.
+// Saves each record's FULL fieldData to a restore JSONL BEFORE deleting (re-createable).
+// DEFAULT = dry-run. --apply fires. Only touches disposition='safe-delete' rows (never CONFLICT-HOLD).
+import fs from 'fs';
+import path from 'path';
+const env = JSON.parse(fs.readFileSync('/tmp/fmenv.json', 'utf8'));
+for (const [k, v] of Object.entries(env)) process.env[k] = v;
+const fm = await import('./src/fm-client.js');
+const DB = 'WALLPAPER', LAYOUT = 'Basic List of Fields';
+const APPLY = process.argv.includes('--apply');
+const Q = String.fromCharCode(34);
+function P(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 raw = P(fs.readFileSync(process.env.HOME + '/Desktop/GRS_dedup_DELETE_plan_20260819.csv', 'utf8'));
+const h = raw.shift(); const ix = Object.fromEntries(h.map((x, i) => [x, i]));
+const targets = raw.filter(r => r.length && r[ix.disposition] === 'safe-delete');
+const RESTORE = process.env.HOME + '/Desktop/GRS_dedup_RESTORE_' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + '.jsonl';
+console.log(`safe-delete targets: ${targets.length} (${APPLY ? 'LIVE DELETE' : 'DRY-RUN'})`);
+if (!APPLY) { console.log(' [dry-run] no deletions. Re-run with --apply.'); process.exit(0); }
+let done = 0, fail = 0;
+for (const r of targets) {
+ const recId = r[ix.delete_record_id];
+ try {
+ const rec = await fm.getRecord(DB, LAYOUT, recId).catch(() => null);
+ const fd = rec?.records?.[0]?.fieldData || rec?.data?.fieldData || null;
+ fs.appendFileSync(RESTORE, JSON.stringify({ recId, combo: r[ix.combo], keeper: r[ix.keeper_record_id], fieldData: fd }) + '\n');
+ await fm.deleteRecord(DB, LAYOUT, recId);
+ done++; if (done % 25 === 0) console.log(` ...deleted ${done}/${targets.length}`);
+ } catch (e) { fail++; console.error(` FAIL rec ${recId}: ${e.message}`); }
+}
+console.log(`\nLIVE dedup: deleted ${done}, failed ${fail}. Restore -> ${RESTORE}`);
diff --git a/grs-mfr-apply.mjs b/grs-mfr-apply.mjs
new file mode 100644
index 0000000..bb01c71
--- /dev/null
+++ b/grs-mfr-apply.mjs
@@ -0,0 +1,114 @@
+#!/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`);
diff --git a/grs-mfr-restore.mjs b/grs-mfr-restore.mjs
new file mode 100644
index 0000000..7c088bb
--- /dev/null
+++ b/grs-mfr-restore.mjs
@@ -0,0 +1,25 @@
+#!/usr/bin/env node
+// Reverse a GRS mfr apply from its restore JSONL: put back old dwc/custom metafields + mirror column.
+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, DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const PSQL = 'psql -h /tmp -d dw_unified -tAc';
+const FILE = process.argv[2]; if (!FILE) { console.error('usage: node grs-mfr-restore.mjs <RESTORE.jsonl>'); process.exit(1); }
+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, "''");
+const lines = fs.readFileSync(FILE, 'utf8').split('\n').filter(Boolean).map(JSON.parse);
+let n = 0;
+for (const r of lines) {
+ const gid = `gid://shopify/Product/${r.product_id}`;
+ await gql(`mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ message } } }`,
+ { mf: [
+ { ownerId: gid, namespace: 'dwc', key: 'manufacturer_sku', type: 'single_line_text_field', value: r.old_dwc || '' },
+ { ownerId: gid, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: r.old_custom || '' },
+ ] });
+ execSync(`${PSQL} "UPDATE shopify_products SET mfr_sku='${sqlesc(r.old_mirror)}' WHERE regexp_replace(shopify_id,'.*/','')='${r.product_id}'"`);
+ n++;
+}
+console.log(`restored ${n} products from ${FILE}`);
diff --git a/plan-grs-dedup.mjs b/plan-grs-dedup.mjs
new file mode 100644
index 0000000..7c7349c
--- /dev/null
+++ b/plan-grs-dedup.mjs
@@ -0,0 +1,42 @@
+// Build a GRS duplicate-master DEDUP PLAN from the FM dump (read-only, no writes).
+// For each combo sku with >1 FileMaker master: pick a KEEPER, list the EXTRAS to delete
+// with full field data saved for restore. Deletion itself is GATED (drafted, not run).
+import fs from 'fs';
+const Q = String.fromCharCode(34);
+function P(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, '');
+const raw = P(fs.readFileSync(process.env.HOME + '/Desktop/GRS_fm_all_records_20260819.csv', 'utf8'));
+const h = raw.shift(); const ix = Object.fromEntries(h.map((x, i) => [x, i]));
+
+function realCode(mfr, js) { const up = (mfr || '').toUpperCase().trim(); if (!up || /DO NOT SELL|CONFUSING|TEST|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 && m[1] !== norm(js)) return m[1]; return null; }
+// richness score: has real code + populated fields
+function score(r) { let s = 0; if (realCode(r[ix.mfr_pattern], r[ix.js_pattern])) s += 100; if (r[ix.jpg_name]) s += 10; if (r[ix.supplier]) s += 5; if (r[ix.net_price]) s += 3; s += Object.values(r).filter(Boolean).length; return s; }
+
+const g = new Map();
+for (const r of raw) { if (!r.length || !r[ix.combo_sku]) continue; const c = norm(r[ix.combo_sku]); (g.get(c) || g.set(c, []).get(c)).push(r); }
+
+let dupGroups = 0, extras = 0, conflictGroups = 0;
+const keepRows = [], delRows = [];
+for (const [combo, recs] of g) {
+ if (recs.length < 2) continue;
+ dupGroups++;
+ const codes = new Set(recs.map(r => realCode(r[ix.mfr_pattern], r[ix.js_pattern])).filter(Boolean));
+ const conflict = codes.size > 1;
+ if (conflict) conflictGroups++;
+ recs.sort((a, b) => score(b) - score(a) || (+a[ix.fm_record_id]) - (+b[ix.fm_record_id]));
+ const keeper = recs[0];
+ keepRows.push([combo, keeper[ix.fm_record_id], keeper[ix.mfr_pattern], recs.length, conflict ? 'CONFLICT' : 'agree']);
+ for (const r of recs.slice(1)) { extras++; delRows.push([combo, r[ix.fm_record_id], r[ix.mfr_pattern], r[ix.js_pattern], r[ix.series], r[ix.supplier], r[ix.net_price], r[ix.jpg_name], keeper[ix.fm_record_id], conflict ? 'CONFLICT-HOLD' : 'safe-delete']); }
+}
+const qq = x => Q + String(x ?? '').replace(/"/g, Q + Q) + Q;
+const delFile = process.env.HOME + '/Desktop/GRS_dedup_DELETE_plan_20260819.csv';
+fs.writeFileSync(delFile, 'combo,delete_record_id,mfr_pattern,js_pattern,series,supplier,net,jpg,keeper_record_id,disposition\n' + delRows.map(r => r.map(qq).join(',')).join('\n') + '\n');
+const safeDel = delRows.filter(r => r[9] === 'safe-delete').length;
+console.log('=== GRS duplicate-master DEDUP PLAN ===');
+console.log(' duplicate combo groups:', dupGroups);
+console.log(' of those, code-CONFLICT groups (masters disagree — HOLD, manual):', conflictGroups);
+console.log(' extra records (keeper picked, these are the dupes):', extras);
+console.log(' -> SAFE to delete (dupes where masters agree/keeper clear):', safeDel);
+console.log(' -> HOLD (conflict groups, need human pick):', extras - safeDel);
+console.log(' plan CSV ->', delFile);
+console.log('\n keeper rule: highest richness (real code > jpg > supplier > populated fields), tie -> lowest record_id');
diff --git a/pull-fm-last1000.mjs b/pull-fm-last1000.mjs
new file mode 100644
index 0000000..75cc239
--- /dev/null
+++ b/pull-fm-last1000.mjs
@@ -0,0 +1,34 @@
+// Pull the LAST N (newest) WALLPAPER records -> CSV. Read-only.
+// Newest = highest internal record order, so we read the tail via offset.
+import fs from 'fs';
+const env = JSON.parse(fs.readFileSync('/tmp/fmenv.json', 'utf8'));
+for (const [k, v] of Object.entries(env)) process.env[k] = v;
+const fm = await import('./src/fm-client.js');
+
+const DB = 'WALLPAPER', LAYOUT = 'Basic List of Fields';
+const N = parseInt(process.argv[2] || '1000', 10);
+const Q = String.fromCharCode(34);
+
+// find total
+const probe = await fm.listRecords(DB, LAYOUT, { limit: 1, offset: 1 });
+const total = probe?.dataInfo?.totalRecordCount;
+const start = Math.max(1, total - N + 1);
+console.error(`total=${total} pulling offset ${start}..${total}`);
+
+const rows = [];
+let offset = start;
+while (offset <= total) {
+ const lim = Math.min(500, total - offset + 1);
+ let res, tries = 0;
+ while (true) { try { res = await fm.listRecords(DB, LAYOUT, { limit: lim, offset }); break; } catch (e) { if (++tries >= 4) throw e; await new Promise(r => setTimeout(r, 1500 * tries)); } }
+ for (const r of (res.records || [])) {
+ const f = r.fieldData || {};
+ rows.push([f['combo sku'], f['Mfr Pattern'], f['JS Pattern'], f['Series'], f['Supplier'], f['Internal Description'], f['JPG Name'], r.recordId, f['Date Line Put Up:'], f['Net Price']]);
+ }
+ offset += lim;
+}
+const OUT = process.env.HOME + '/Desktop/FM_last' + N + '_' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + '.csv';
+const qq = x => Q + String(x ?? '').replace(/"/g, Q + Q).replace(/\r?\n/g, ' ') + Q;
+fs.writeFileSync(OUT, 'combo_sku,mfr_pattern,js_pattern,series,supplier,internal_desc,jpg_name,record_id,date_put_up,net\n' + rows.map(r => r.map(qq).join(',')).join('\n') + '\n');
+console.error('wrote', rows.length, '->', OUT);
+console.log(JSON.stringify({ out: OUT, rows: rows.length, total }));
diff --git a/pull-grs-mfr.mjs b/pull-grs-mfr.mjs
new file mode 100644
index 0000000..e6c94f5
--- /dev/null
+++ b/pull-grs-mfr.mjs
@@ -0,0 +1,47 @@
+// Pull ALL Series=GRS records from FileMaker WALLPAPER -> CSV on disk.
+// Read-only. Reuses the MCP fm-client (Claris ID auth). For the GRS mfr recovery job.
+import fs from 'fs';
+const env = JSON.parse(fs.readFileSync('/tmp/fmenv.json', 'utf8'));
+for (const [k, v] of Object.entries(env)) process.env[k] = v;
+const fm = await import('./src/fm-client.js');
+
+const DB = 'WALLPAPER', LAYOUT = 'Basic List of Fields', PAGE = 500;
+const OUT = process.env.HOME + '/Desktop/GRS_fm_all_records_' +
+ new Date().toISOString().slice(0, 10).replace(/-/g, '') + '.csv';
+
+const csvq = s => '"' + String(s ?? '').replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"';
+const rows = [];
+let offset = 1, total = null, pages = 0;
+const t0 = Date.now();
+
+while (true) {
+ let res, tries = 0;
+ while (true) {
+ try { res = await fm.findRecords(DB, LAYOUT, [{ Series: 'GRS' }], { limit: PAGE, offset }); break; }
+ catch (e) {
+ const msg = String(e?.message || e);
+ if (msg.includes('[401]')) { res = { records: [], dataInfo: { totalRecordCount: total ?? 0 } }; break; } // no more
+ if (++tries >= 4) throw e;
+ await new Promise(r => setTimeout(r, 1500 * tries));
+ }
+ }
+ total = res?.dataInfo?.totalRecordCount ?? total;
+ const recs = res?.records || [];
+ if (!recs.length) break;
+ for (const r of recs) {
+ const f = r.fieldData || {};
+ rows.push([
+ f['combo sku'], f['Mfr Pattern'], f['JS Pattern'], f['Series'],
+ f['Supplier'], f['Net Price'], f['Width'], f['JPG Name'],
+ r.recordId, f['Date Line Put Up:']
+ ]);
+ }
+ pages++; offset += PAGE;
+ if (pages % 2 === 0) console.error(` page ${pages} offset ${offset - 1}/${total} rows=${rows.length} ${((Date.now() - t0) / 1000 | 0)}s`);
+ if (total && offset > total) break;
+}
+
+const header = ['combo_sku', 'mfr_pattern', 'js_pattern', 'series', 'supplier', 'net_price', 'width', 'jpg_name', 'fm_record_id', 'date_put_up'];
+fs.writeFileSync(OUT, header.join(',') + '\n' + rows.map(r => r.map(csvq).join(',')).join('\n') + '\n');
+console.error(`DONE pages=${pages} total=${total} rows=${rows.length} -> ${OUT} ${((Date.now() - t0) / 1000 | 0)}s`);
+console.log(JSON.stringify({ out: OUT, rows: rows.length, total }));
diff --git a/scan-grs-wrong-mfr.mjs b/scan-grs-wrong-mfr.mjs
new file mode 100644
index 0000000..1d76b7b
--- /dev/null
+++ b/scan-grs-wrong-mfr.mjs
@@ -0,0 +1,36 @@
+import fs from 'fs';
+const Q = String.fromCharCode(34);
+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 raw = parseCSV(fs.readFileSync(process.env.HOME + '/Desktop/GRS_fm_all_records_20260819.csv', 'utf8'));
+const h = raw.shift(); const ix = Object.fromEntries(h.map((x, i) => [x, i]));
+const norm = s => (s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+let wrong = [], blank = 0, total = 0;
+for (const r of raw) {
+ if (!r.length || !r[ix.combo_sku]) continue; total++;
+ const combo = norm(r[ix.combo_sku]); // GRS43023
+ const num = norm(r[ix.js_pattern]); // 43023
+ const mfr = norm(r[ix.mfr_pattern]); // SC5823 / 43023 / GRS43023
+ if (!mfr) { blank++; continue; }
+ if (mfr === num || mfr === combo || mfr === 'GRS' + num) {
+ wrong.push([r[ix.combo_sku], r[ix.mfr_pattern], r[ix.js_pattern], r[ix.supplier], r[ix.fm_record_id]]);
+ }
+}
+console.log('total GRS FM records:', total);
+console.log('records with DW-SKU-in-Mfr (WRONG):', wrong.length);
+console.log('records with BLANK Mfr:', blank);
+const out = process.env.HOME + '/Desktop/GRS_fm_WRONG_mfr_is_dwsku_20260819.csv';
+const qq = x => Q + String(x ?? '').replace(/"/g, Q + Q) + Q;
+fs.writeFileSync(out, 'combo_sku,mfr_pattern,js_pattern,supplier,fm_record_id\n' + wrong.map(p => p.map(qq).join(',')).join('\n') + '\n');
+console.log('wrote ->', out);
+console.log('\nsample WRONG records:');
+for (const p of wrong.slice(0, 15)) console.log(' ' + p[0] + ' | Mfr=' + JSON.stringify(p[1]) + ' | supplier=' + JSON.stringify(p[3]) + ' | recId=' + p[4]);
← df2aeae chore: v0.3.1 (session close) — FMPro dashboard push local-r
·
back to Filemaker Mcp
·
fm-client: dedupe guard on createRecord — block duplicate WA 0ac2c19 →