[object Object]

← 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

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,'&quot;')}">▸ 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[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 →