← back to Mfr Review Viewer Corruption
mfr-review-viewer: per-SKU candidates drill-down (FmPro + unified DB) + creds-in-URL fetch guard
a93a88be342e0d6b5df20cda5a683e7c9c6b52da · 2026-08-27 08:18:37 -0700 · Steve Abrams
Each chip gets a '▸ candidates' expander that lazy-fetches GET /api/candidates?dw_sku=<sku>
and renders two groups: FmPro records (every matching WALLPAPER master, so duplicate/wrong
masters are visible) and Unified DB rows (shopify_products/vendor_catalog/dw_sku_registry).
Selecting a candidate's radio writes its real mfr (noteMfr / metafield / mfrPattern) into the
row's confirmed-mfr input for gated staging; a 'none — keep manual' radio is included.
Backend lib/candidates.mjs is READ-ONLY (FM _find + psql SELECTs), imports findRecords from
filemaker-mcp (does not modify it), degrades to {filemaker:[],unified:[...],fmError} on FM
failure. The real mfr note ('#gz127 - $44.10 Net - 1/14') lives in FileMaker field 'Mfr Pattern';
noteMfr parses the token after '#'. Added the fleet creds-in-URL window.fetch guard (the
embedded-creds baseURI trap was silently breaking every fetch, queue + candidates alike).
Verified: GET /api/candidates?dw_sku=HSW-51526 -> 18 FmPro masters (gz127 surfaces, duplicate
masters visible), 3 unified rows; radio-pick writes gz127 into the confirmed-mfr input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A data/candidates-proof-HSW-51526.pngA lib/candidates.mjsM public/index.htmlM server.js
Diff
commit a93a88be342e0d6b5df20cda5a683e7c9c6b52da
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 27 08:18:37 2026 -0700
mfr-review-viewer: per-SKU candidates drill-down (FmPro + unified DB) + creds-in-URL fetch guard
Each chip gets a '▸ candidates' expander that lazy-fetches GET /api/candidates?dw_sku=<sku>
and renders two groups: FmPro records (every matching WALLPAPER master, so duplicate/wrong
masters are visible) and Unified DB rows (shopify_products/vendor_catalog/dw_sku_registry).
Selecting a candidate's radio writes its real mfr (noteMfr / metafield / mfrPattern) into the
row's confirmed-mfr input for gated staging; a 'none — keep manual' radio is included.
Backend lib/candidates.mjs is READ-ONLY (FM _find + psql SELECTs), imports findRecords from
filemaker-mcp (does not modify it), degrades to {filemaker:[],unified:[...],fmError} on FM
failure. The real mfr note ('#gz127 - $44.10 Net - 1/14') lives in FileMaker field 'Mfr Pattern';
noteMfr parses the token after '#'. Added the fleet creds-in-URL window.fetch guard (the
embedded-creds baseURI trap was silently breaking every fetch, queue + candidates alike).
Verified: GET /api/candidates?dw_sku=HSW-51526 -> 18 FmPro masters (gz127 surfaces, duplicate
masters visible), 3 unified rows; radio-pick writes gz127 into the confirmed-mfr input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
data/candidates-proof-HSW-51526.png | Bin 0 -> 144518 bytes
lib/candidates.mjs | 252 ++++++++++++++++++++++++++++++++++++
public/index.html | 170 ++++++++++++++++++++++++
server.js | 19 +++
4 files changed, 441 insertions(+)
diff --git a/data/candidates-proof-HSW-51526.png b/data/candidates-proof-HSW-51526.png
new file mode 100644
index 0000000..0b08151
Binary files /dev/null and b/data/candidates-proof-HSW-51526.png differ
diff --git a/lib/candidates.mjs b/lib/candidates.mjs
new file mode 100644
index 0000000..af5b83b
--- /dev/null
+++ b/lib/candidates.mjs
@@ -0,0 +1,252 @@
+// candidates.mjs — READ-ONLY per-SKU candidate resolver for the mfr-review-viewer.
+//
+// For ONE dw_sku, returns EVERY matching record in:
+// • FileMaker (FmPro) db 'WALLPAPER', layout '*List Wallpapers - Full View'
+// • the unified dw_unified Postgres DB (shopify_products, vendor_catalog, dw_sku_registry)
+// so a reviewer can SEE the duplicate/wrong masters and pick the correct real mfr code.
+//
+// PURELY READ-ONLY: FileMaker _find calls + psql SELECTs only. Never writes anywhere.
+// Imports findRecords from the filemaker-mcp project (does NOT modify that project),
+// loading its .env for the FM Cloud creds. FM auth/timeout failures degrade gracefully
+// to { filemaker:[], unified:[...], fmError:'...' } so the unified side still renders.
+//
+// SKU-match logic mirrors filemaker-mcp/scripts/fuzzy-sku-check.mjs + lib/wallpaper.js
+// (parseCombo / splitCandidates / findExistingMaster).
+
+import { readFileSync, existsSync } from 'node:fs';
+import { execFile } from 'node:child_process';
+import path from 'node:path';
+import os from 'node:os';
+
+const FM_PROJECT = path.join(os.homedir(), 'Projects', 'filemaker-mcp');
+const FM_ENV = path.join(FM_PROJECT, '.env');
+const FM_CLIENT = path.join(FM_PROJECT, 'src', 'fm-client.js');
+const FM_DB = 'WALLPAPER';
+const FM_LAYOUT = '*List Wallpapers - Full View';
+const PSQL = process.env.PSQL_BIN || '/opt/homebrew/opt/postgresql@14/bin/psql';
+
+// ---- load the filemaker-mcp .env (read-only) so FM Cloud creds are present ----
+function loadFmEnv() {
+ if (!existsSync(FM_ENV)) return;
+ for (const line of readFileSync(FM_ENV, 'utf8').split('\n')) {
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
+ }
+}
+loadFmEnv();
+
+// lazy-import findRecords (ES module) once, cached
+let _findRecords = null;
+async function getFindRecords() {
+ if (_findRecords) return _findRecords;
+ const mod = await import('file://' + FM_CLIENT);
+ _findRecords = mod.findRecords;
+ return _findRecords;
+}
+
+// ---------------- SKU normalization (mirrors wallpaper.js) ----------------
+const normSku = (s) => String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+
+// Strip a trailing -Sample / unit suffix, collapse whitespace.
+function cleanSku(raw) {
+ return String(raw || '').trim().replace(/[-_ ]?sample$/i, '');
+}
+
+// Enumerate every {Series, JS Pattern} split at each separator boundary.
+function splitCandidates(dashForm) {
+ const s = String(dashForm || '').trim();
+ const out = [];
+ for (let i = 0; i < s.length; i++) {
+ if (/[-_ ]/.test(s[i])) {
+ const series = s.slice(0, i);
+ const pattern = s.slice(i + 1);
+ if (series && pattern) out.push([series, pattern]);
+ }
+ }
+ return out;
+}
+
+// The numeric tail of a SKU (the "pattern number") — e.g. HSW-51526 -> 51526.
+function numericTail(clean) {
+ const m = String(clean || '').match(/(\d[\d]*)\s*$/);
+ return m ? m[1] : '';
+}
+
+// Parse the real mfr code out of the Mfr Pattern / note text.
+// "#gz127 - $44.10 Net - 1/14" -> "gz127"; "AMW10042-6; Library-Leather" -> "AMW10042-6";
+// "15712" -> "15712". Token after an optional leading '#', up to the first space / ' - ' / ';'.
+function parseNoteMfr(note) {
+ let t = String(note || '').trim();
+ if (!t) return '';
+ // take first line only
+ t = t.split(/[\r\n]/)[0].trim();
+ // if there's an embedded "#code" anywhere, prefer that token
+ const hash = t.match(/#\s*([^\s;,-][^\s;,]*)/);
+ if (hash) return hash[1].replace(/[.,]$/, '').trim();
+ // otherwise the leading token before a separator that starts a cost/date note
+ // "AMW10042-6; Library-Leather" -> AMW10042-6 (stop at ';')
+ // "p622_17 - $89 Net" -> p622_17 (stop at ' - ')
+ let m = t.match(/^([^\s;]+?)(?:\s*;|\s+-\s+|\s+\$|\s+net\b|$)/i);
+ if (m) return m[1].replace(/[.,]$/, '').trim();
+ return t;
+}
+
+// FileMaker codes meaning "no match / field not on layout" (skip) vs a real error.
+const FM_SKIP = new Set(['401', '102', '105', '106']);
+
+// ---------------- FileMaker candidates ----------------
+async function fmCandidates(dwSku) {
+ let findRecords;
+ try {
+ findRecords = await getFindRecords();
+ } catch (e) {
+ return { filemaker: [], fmError: 'fm-client load failed: ' + e.message };
+ }
+
+ const clean = cleanSku(dwSku);
+ const dashless = clean.replace(/[-_ ]/g, '');
+ const dashed = clean.includes('-') ? clean : (dashless.match(/^([A-Za-z]+)(\d.*)$/) ? dashless.replace(/^([A-Za-z]+)(\d.*)$/, '$1-$2') : clean);
+ const tail = numericTail(clean);
+
+ // OR-array of every field a SKU can live in + every component split.
+ const query = [];
+ const pushEq = (field, val) => { if (val) query.push({ [field]: '==' + val }); };
+ pushEq('combo sku', dashless);
+ pushEq('comboskuwithdash', dashed);
+ pushEq('mfr pattern number', tail || clean);
+ pushEq('Mfr Pattern', tail || clean);
+ // component splits (Series==X, JS Pattern==Y) over both dashed + raw forms
+ const seenSplit = new Set();
+ for (const src of [dashed, clean, dashless]) {
+ for (const [series, pattern] of splitCandidates(src)) {
+ const k = series.toUpperCase() + '|' + pattern.toUpperCase();
+ if (seenSplit.has(k)) continue;
+ seenSplit.add(k);
+ query.push({ Series: '==' + series, 'JS Pattern': '==' + pattern });
+ }
+ }
+ if (!query.length) return { filemaker: [], fmError: null };
+
+ let records = [];
+ try {
+ const r = await findRecords(FM_DB, FM_LAYOUT, query, { limit: 50 });
+ records = r.records || [];
+ } catch (e) {
+ const code = String(e.fmCode || '');
+ if (FM_SKIP.has(code)) return { filemaker: [], fmError: null }; // genuinely no match
+ return { filemaker: [], fmError: `FileMaker ${code || ''} ${e.message}`.trim() };
+ }
+
+ // Dedupe by recordId; project to the fields the UI needs.
+ const targetKey = normSku(clean);
+ const byId = new Map();
+ for (const rec of records) {
+ const fd = rec.fieldData || {};
+ // Accept the record if its Series+JS Pattern OR its combo sku normalizes to the target,
+ // OR it matched via an mfr/tail probe (keep it — reviewer decides). We include all
+ // returned records so the reviewer can SEE duplicates; but flag the confident matches.
+ const storedKey = normSku(String(fd.Series || '') + String(fd['JS Pattern'] || ''));
+ const comboKey = normSku(fd['combo sku']);
+ const skuMatch = storedKey === targetKey || comboKey === targetKey;
+
+ const mfrPattern = String(fd['Mfr Pattern'] || '').trim();
+ // The "note" that holds the real code: Mfr Pattern is the primary carrier (it holds
+ // "#gz127" for HSW-51526). We ALSO surface the sample-chase memo field if it carries a
+ // "#code" (fallback). Both are read-only.
+ const chaseMemo = String(fd['Vendor Sample - Where is Memo Send 2nd day'] || '').trim();
+ const mfrNote = mfrPattern || (/#/.test(chaseMemo) ? chaseMemo : '');
+ const noteMfr = parseNoteMfr(mfrNote) || parseNoteMfr(chaseMemo);
+
+ const cand = {
+ recordId: rec.recordId,
+ comboSku: String(fd['combo sku'] || '').trim(),
+ comboSkuWithDash: String(fd['comboskuwithdash'] || fd['seriesdashnumber'] || '').trim(),
+ series: String(fd.Series || '').trim(),
+ jsPattern: String(fd['JS Pattern'] || '').trim(),
+ mfrPattern, // STRUCTURED mfr (may be the real code or a DW#)
+ name: String(fd['Name of Pattern'] || '').trim(),
+ color: String(fd['Color of Pattern'] || '').trim(),
+ vid: String(fd.vid || '').trim(),
+ supplier: String(fd.Supplier || '').trim(),
+ width: String(fd.Width || '').trim(),
+ cost: String(fd.Cost || fd['Updated Vendor Cost'] || fd['DW New Net'] || '').trim(),
+ mfrNote, // free-text carrier of the real code
+ noteMfr, // best-guess real mfr parsed from the note
+ skuMatch, // true = Series/combo normalizes to this dw_sku
+ };
+ byId.set(rec.recordId, cand);
+ }
+ // Confident sku-matches first, then the rest.
+ const out = [...byId.values()].sort((a, b) => (b.skuMatch - a.skuMatch));
+ return { filemaker: out, fmError: null };
+}
+
+// ---------------- unified DB candidates ----------------
+function psqlJson(sql) {
+ return new Promise((resolve) => {
+ execFile(PSQL, ['dw_unified', '-tAc', sql], { maxBuffer: 1024 * 1024 * 64 }, (err, stdout) => {
+ if (err) return resolve(null);
+ const txt = (stdout || '').trim();
+ if (!txt) return resolve([]);
+ try { return resolve(JSON.parse(txt)); } catch { return resolve([]); }
+ });
+ });
+}
+
+async function unifiedCandidates(dwSku) {
+ const clean = cleanSku(dwSku);
+ const norm = normSku(clean); // HSW51526
+ const tail = numericTail(clean); // 51526
+ const esc = (s) => String(s).replace(/'/g, "''");
+ const nq = `'${esc(norm)}'`;
+ const tailPred = tail
+ ? `OR regexp_replace(coalesce(mfr_sku,''),'[^0-9]','','g') = '${esc(tail)}'
+ OR regexp_replace(coalesce(dw_sku,''),'[^0-9]','','g') = '${esc(tail)}'`
+ : '';
+
+ // shopify_products
+ const shopSql = `SELECT coalesce(json_agg(row_to_json(t)),'[]') FROM (
+ SELECT 'shopify_products' AS source_table, dw_sku, sku, variant_sku, mfr_sku, vendor,
+ supplier_name, pattern_name, status,
+ metafields->'custom'->'manufacturer_sku'->>'value' AS manufacturer_sku
+ FROM shopify_products
+ WHERE upper(regexp_replace(coalesce(dw_sku,''),'[^A-Za-z0-9]','','g')) = ${nq}
+ OR upper(regexp_replace(coalesce(sku,''),'[^A-Za-z0-9]','','g')) = ${nq}
+ OR upper(regexp_replace(coalesce(variant_sku,''),'[^A-Za-z0-9]','','g')) = ${nq}
+ ${tail ? `OR regexp_replace(coalesce(mfr_sku,''),'[^0-9]','','g') = '${esc(tail)}'` : ''}
+ LIMIT 50 ) t`;
+
+ // vendor_catalog (vendor_code carries the vendor; no supplier_name/vendor cols)
+ const vcSql = `SELECT coalesce(json_agg(row_to_json(t)),'[]') FROM (
+ SELECT 'vendor_catalog' AS source_table, dw_sku, NULL::text AS sku, NULL::text AS variant_sku,
+ mfr_sku, vendor_code AS vendor, NULL::text AS supplier_name, pattern_name,
+ NULL::text AS status, NULL::text AS manufacturer_sku
+ FROM vendor_catalog
+ WHERE upper(regexp_replace(coalesce(dw_sku,''),'[^A-Za-z0-9]','','g')) = ${nq}
+ OR upper(regexp_replace(coalesce(mfr_sku,''),'[^A-Za-z0-9]','','g')) = ${nq}
+ ${tail ? `OR regexp_replace(coalesce(mfr_sku,''),'[^0-9]','','g') = '${esc(tail)}'` : ''}
+ LIMIT 50 ) t`;
+
+ // dw_sku_registry
+ const regSql = `SELECT coalesce(json_agg(row_to_json(t)),'[]') FROM (
+ SELECT 'dw_sku_registry' AS source_table, dw_sku, NULL::text AS sku, NULL::text AS variant_sku,
+ mfr_sku, vendor_name AS vendor, NULL::text AS supplier_name, NULL::text AS pattern_name,
+ status, NULL::text AS manufacturer_sku
+ FROM dw_sku_registry
+ WHERE upper(regexp_replace(coalesce(dw_sku,''),'[^A-Za-z0-9]','','g')) = ${nq}
+ OR upper(regexp_replace(coalesce(mfr_sku,''),'[^A-Za-z0-9]','','g')) = ${nq}
+ ${tail ? `OR regexp_replace(coalesce(mfr_sku,''),'[^0-9]','','g') = '${esc(tail)}'` : ''}
+ LIMIT 50 ) t`;
+
+ const [shop, vc, reg] = await Promise.all([psqlJson(shopSql), psqlJson(vcSql), psqlJson(regSql)]);
+ return [...(shop || []), ...(vc || []), ...(reg || [])];
+}
+
+// ---------------- public entry ----------------
+export async function candidatesForSku(dwSku) {
+ const [fm, unified] = await Promise.all([
+ fmCandidates(dwSku).catch((e) => ({ filemaker: [], fmError: e.message })),
+ unifiedCandidates(dwSku).catch(() => []),
+ ]);
+ return { filemaker: fm.filemaker || [], unified: unified || [], fmError: fm.fmError || null };
+}
diff --git a/public/index.html b/public/index.html
index 64e491d..6530c63 100644
--- a/public/index.html
+++ b/public/index.html
@@ -76,6 +76,31 @@
.b.tier-LIKELY-LEGIT { color:var(--muted); }
.when { color:var(--muted); font-size:11px; }
+ /* ---- Candidates drill-down ---- */
+ .cand-toggle { align-self:flex-start; margin-top:2px; font-size:11px; color:var(--teal);
+ background:none; border:none; padding:2px 0; cursor:pointer; }
+ .cand-toggle:hover { text-decoration:underline; }
+ .cand-panel { margin-top:4px; border-top:1px dashed var(--line); padding-top:6px;
+ display:flex; flex-direction:column; gap:8px; }
+ .cand-group h4 { margin:0 0 4px; font-size:11px; color:var(--muted); text-transform:uppercase;
+ letter-spacing:.04em; }
+ .cand-row { display:flex; gap:6px; align-items:flex-start; padding:4px 5px; font-size:11px;
+ border:1px solid var(--line); border-radius:6px; background:#12161b; cursor:pointer; }
+ .cand-row:hover { border-color:var(--teal); }
+ .cand-row.picked { border-color:var(--teal); box-shadow:0 0 0 1px var(--teal) inset; }
+ .cand-row input[type=radio] { margin-top:2px; }
+ .cand-body { display:flex; flex-direction:column; gap:2px; min-width:0; }
+ .cand-line { display:flex; flex-wrap:wrap; gap:6px; }
+ .cand-rid { color:#a5b4fc; font-family:ui-monospace,monospace; }
+ .cand-note { color:var(--green); font-weight:700; font-family:ui-monospace,monospace;
+ background:rgba(34,197,94,.12); padding:1px 5px; border-radius:4px; }
+ .cand-struct { font-variant-numeric:tabular-nums; }
+ .cand-struct.isdw { color:var(--red); text-decoration:line-through; }
+ .cand-meta { color:var(--muted); }
+ .cand-realmfr { color:var(--teal); font-family:ui-monospace,monospace; font-weight:600; }
+ .cand-spin { color:var(--muted); font-size:11px; padding:4px 0; }
+ .cand-err { color:var(--amber); font-size:11px; }
+
/* ---- Sticky action bar ---- */
.action { position:fixed; left:280px; right:0; bottom:0; background:var(--panel);
border-top:1px solid var(--line); padding:12px 16px; display:flex; align-items:center;
@@ -137,10 +162,29 @@
</div>
<script>
+// --- creds-in-URL fetch guard (fleet pattern) ------------------------------
+// When opened as http://user:pass@host/ the embedded creds poison document.baseURI,
+// so a relative fetch('api/…') throws "Request cannot be constructed from a URL that
+// includes credentials". Resolve relative URLs against a credential-free origin.
+(function(){
+ const _fetch = window.fetch.bind(window);
+ const clean = new URL(location.pathname, location.origin).href; // no user:pass
+ window.fetch = (input, init)=>{
+ try {
+ if (typeof input === 'string' && !/^https?:|^\/\//i.test(input)) {
+ input = new URL(input, clean).href;
+ }
+ } catch(_) {}
+ return _fetch(input, init);
+ };
+})();
+
const state = {
rows: [], facets: {}, filters: {}, search: '',
sel: new Set(), // dw_sku set
edits: {}, // dw_sku -> edited guess
+ cand: {}, // dw_sku -> {filemaker,unified,fmError} (cached per SKU)
+ candPick: {}, // dw_sku -> chosen candidate key (for radio state)
fields: JSON.parse(localStorage.getItem('mfr.fields')||'{}'),
sort: localStorage.getItem('mfr.sort')||'newest',
density: +(localStorage.getItem('mfr.density')||4),
@@ -252,6 +296,8 @@ function apply(){
<span class="b tier-${r.tier}">${r.tier}</span>
${r.peer?'<span class="b" style="color:#f0abfc">peer</span>':''}</div>`;
if(fieldOn('when')) html += `<div class="when" title="${r.created_at||''}">${fmtWhen(r.created_at)}</div>`;
+ html += `<button class="cand-toggle" data-sku="${r.dw_sku.replace(/"/g,'"')}">▸ candidates</button>
+ <div class="cand-panel hidden"></div>`;
card.innerHTML = html;
const chk = card.querySelector('.chk');
@@ -259,6 +305,8 @@ function apply(){
card.classList.toggle('sel',chk.checked); updateSel(); };
const gi = card.querySelector('.guess-in');
if(gi) gi.oninput = ()=>{ state.edits[r.dw_sku]=gi.value; };
+ const tog = card.querySelector('.cand-toggle');
+ tog.onclick = ()=>toggleCandidates(r.dw_sku, tog, card.querySelector('.cand-panel'));
frag.appendChild(card);
}
grid.appendChild(frag);
@@ -274,6 +322,128 @@ function updateSel(){
$('#stageBtn').disabled = state.sel.size===0;
}
+// ---- Candidates drill-down (lazy per chip) ----
+const esc = (s)=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'})[c]);
+
+async function toggleCandidates(sku, tog, panel){
+ const open = !panel.classList.contains('hidden');
+ if(open){ panel.classList.add('hidden'); tog.textContent='▸ candidates'; return; }
+ panel.classList.remove('hidden'); tog.textContent='▾ candidates';
+ if(state.cand[sku]){ renderCandidates(sku, panel); return; }
+ panel.innerHTML = '<div class="cand-spin">Loading candidates…</div>';
+ try {
+ const r = await fetch('api/candidates?dw_sku='+encodeURIComponent(sku));
+ const j = await r.json();
+ state.cand[sku] = j;
+ renderCandidates(sku, panel);
+ } catch(e){
+ panel.innerHTML = '<div class="cand-err">Failed to load candidates: '+esc(e.message)+'</div>';
+ }
+}
+
+// Write a chosen real mfr into this row's confirmed-mfr input + edits state, re-render card.
+function chooseMfr(sku, value){
+ state.edits[sku] = value;
+ state.candPick[sku] = value; // remember pick (may be '' for none)
+ // Update the live input in the card WITHOUT a full re-render (keeps the panel open).
+ for(const card of document.querySelectorAll('.card')){
+ const t = card.querySelector('.cand-toggle');
+ if(t && t.dataset.sku===sku){
+ const gi = card.querySelector('.guess-in');
+ if(gi) gi.value = value;
+ break;
+ }
+ }
+}
+
+function candKey(c){ return 'fm:'+c.recordId; }
+
+function renderCandidates(sku, panel){
+ const j = state.cand[sku] || {filemaker:[],unified:[]};
+ const fm = j.filemaker||[], un = j.unified||[];
+ const pick = state.candPick[sku];
+ const norm = (s)=>String(s||'').toUpperCase().replace(/[^A-Z0-9]/g,'');
+ const skuTail = (sku.match(/(\d+)\s*$/)||[])[1]||'';
+ let h = '';
+
+ // ---- FmPro group ----
+ h += `<div class="cand-group"><h4>FmPro records (${fm.length})</h4>`;
+ if(!fm.length){
+ h += `<div class="cand-meta">no FileMaker master found`+(j.fmError?` — <span class="cand-err">${esc(j.fmError)}</span>`:'')+`</div>`;
+ } else {
+ for(const c of fm){
+ // struck-red only when the STRUCTURED Mfr Pattern is just the DW# placeholder
+ // (equals the dw_sku's numeric tail / normalizes to the dw_sku) rather than a real code.
+ const structNorm = norm(c.mfrPattern);
+ const isDw = c.mfrPattern && (structNorm===norm(sku) || (skuTail && structNorm===skuTail) || /^\d+$/.test(c.mfrPattern) && c.mfrPattern===skuTail);
+ const real = c.noteMfr || c.mfrPattern || '';
+ const key = candKey(c);
+ const picked = pick!=null && pick===real && real!=='';
+ h += `<label class="cand-row${picked?' picked':''}">
+ <input type="radio" name="cand-${esc(sku)}" ${picked?'checked':''} data-real="${esc(real)}">
+ <span class="cand-body">
+ <span class="cand-line"><span class="cand-rid">#${esc(c.recordId)}</span>`+
+ (c.series||c.jsPattern?`<span class="cand-meta">${esc(c.series)}|${esc(c.jsPattern)}</span>`:'')+
+ (c.mfrPattern?`<span class="cand-struct${isDw?' isdw':''}" title="Mfr Pattern (structured)">${esc(c.mfrPattern)}</span>`:'')+
+ `</span>`+
+ (c.mfrNote && c.mfrNote!==c.mfrPattern?`<span class="cand-line"><span class="cand-note" title="mfr note (real code lives here)">${esc(c.mfrNote)}</span></span>`:'')+
+ (c.mfrNote===c.mfrPattern && /#|net|\$/i.test(c.mfrNote||'')?`<span class="cand-line"><span class="cand-note" title="real code">${esc(c.mfrNote)}</span></span>`:'')+
+ `<span class="cand-line cand-meta">`+
+ (c.name?`${esc(c.name)} · `:'')+(c.color?`${esc(c.color)} · `:'')+(c.vid?`vid ${esc(c.vid)}`:'')+
+ `</span>`+
+ (real?`<span class="cand-line">→ real mfr: <span class="cand-realmfr">${esc(real)}</span></span>`:'')+
+ `</span></label>`;
+ }
+ }
+ h += `</div>`;
+
+ // ---- Unified group ----
+ h += `<div class="cand-group"><h4>Unified DB rows (${un.length})</h4>`;
+ if(!un.length){
+ h += `<div class="cand-meta">no dw_unified rows matched</div>`;
+ } else {
+ for(let i=0;i<un.length;i++){
+ const u = un[i];
+ const real = u.manufacturer_sku || u.mfr_sku || '';
+ const picked = pick!=null && pick===real && real!=='';
+ h += `<label class="cand-row${picked?' picked':''}">
+ <input type="radio" name="cand-${esc(sku)}" ${picked?'checked':''} data-real="${esc(real)}">
+ <span class="cand-body">
+ <span class="cand-line"><span class="cand-rid">${esc(u.source_table)}</span>`+
+ (u.dw_sku?`<span class="cand-meta">${esc(u.dw_sku)}</span>`:'')+
+ (u.status?`<span class="cand-meta">${esc(u.status)}</span>`:'')+`</span>`+
+ `<span class="cand-line cand-meta">`+
+ (u.mfr_sku?`mfr_sku ${esc(u.mfr_sku)} · `:'')+
+ (u.manufacturer_sku?`mfg-metafield ${esc(u.manufacturer_sku)} · `:'')+
+ (u.vendor?`${esc(u.vendor)} · `:'')+(u.pattern_name?`${esc(u.pattern_name)}`:'')+
+ `</span>`+
+ (real?`<span class="cand-line">→ real mfr: <span class="cand-realmfr">${esc(real)}</span></span>`:'')+
+ `</span></label>`;
+ }
+ }
+ h += `</div>`;
+
+ // ---- none / keep manual ----
+ const noneChecked = (pick==='' );
+ h += `<label class="cand-row${noneChecked?' picked':''}">
+ <input type="radio" name="cand-${esc(sku)}" ${noneChecked?'checked':''} data-real="__none__">
+ <span class="cand-body"><span class="cand-line cand-meta">none — keep manual entry</span></span></label>`;
+
+ panel.innerHTML = h;
+
+ // wire radios
+ for(const rb of panel.querySelectorAll('input[type=radio]')){
+ rb.onchange = ()=>{
+ const real = rb.dataset.real;
+ if(real==='__none__'){ state.candPick[sku]=''; /* leave edits as-is (manual) */ }
+ else chooseMfr(sku, real);
+ // update picked outline
+ for(const row of panel.querySelectorAll('.cand-row')) row.classList.remove('picked');
+ rb.closest('.cand-row').classList.add('picked');
+ };
+ }
+}
+
$('#search').oninput = (e)=>{ state.search=e.target.value; apply(); };
$('#clearFilters').onclick = ()=>{ state.filters={}; state.search=''; $('#search').value=''; renderFacets(); apply(); };
$('#sort').onchange = (e)=>{ state.sort=e.target.value; localStorage.setItem('mfr.sort',state.sort); apply(); };
diff --git a/server.js b/server.js
index 13ba529..65caa06 100644
--- a/server.js
+++ b/server.js
@@ -66,6 +66,25 @@ app.get('/api/queue', (_req, res) => {
});
});
+// GET /api/candidates?dw_sku=<sku> — lazy per-chip drill-down. READ-ONLY.
+// Returns { filemaker:[...], unified:[...], fmError } for ONE dw_sku: every matching
+// FileMaker (FmPro) master + every dw_unified row, so the reviewer can pick the right
+// record and read its real mfr code. Never writes anywhere. Lazy ES-module import so a
+// FileMaker/creds problem can't break the CJS server startup.
+let _candidatesMod = null;
+app.get('/api/candidates', async (req, res) => {
+ const dwSku = String((req.query && req.query.dw_sku) || '').trim();
+ if (!dwSku) return res.status(400).json({ filemaker: [], unified: [], fmError: 'dw_sku required' });
+ try {
+ if (!_candidatesMod) _candidatesMod = await import('./lib/candidates.mjs');
+ const out = await _candidatesMod.candidatesForSku(dwSku);
+ res.json(out);
+ } catch (e) {
+ // Degrade gracefully — never 500 out the whole viewer over a FM/creds hiccup.
+ res.json({ filemaker: [], unified: [], fmError: e.message });
+ }
+});
+
// POST /api/rebuild — re-run the loader (behind auth). READ-ONLY vs dw_unified.
app.post('/api/rebuild', (_req, res) => {
execFile('node', [LOADER], { maxBuffer: 1024 * 1024 * 256 }, (err, stdout, stderr) => {
← ffd4b1a mfr-review-viewer: authenticated viewer for broken DW#==Mfr
·
back to Mfr Review Viewer Corruption
·
auto-data-snapshot: 2026-08-27T08:19:36 (1 data files) — dat b16f1e1 →