← back to Filemaker Mcp

scripts/mfr-review-queue.mjs

175 lines

// TK-10906 — READ-ONLY mfr review-queue generator.
//
// For a given vendor list, emit a per-item review queue of the BROKEN (DW#==mfr) rows with a
// BEST-GUESS real mfr + its source + a confidence, for STAFF to confirm before any write.
// The actual mfr repair is Steve-gated and runs SEPARATELY from this queue after he confirms
// the vendor set. This tool makes ZERO writes to dw_unified, FileMaker, or Shopify — it only
// SELECTs from the local dw_unified mirror and (optionally) does READ-ONLY FileMaker finds.
//
//   node scripts/mfr-review-queue.mjs                 # default Tier-3 vendor set, ~50 rows
//   node scripts/mfr-review-queue.mjs --limit 50      # cap total rows
//   node scripts/mfr-review-queue.mjs --no-fm         # skip the FileMaker-note lookups (pure PG)
//   node scripts/mfr-review-queue.mjs --vendors "Glitter Walls,Wolf Gordon"
//
// Output: data/mfr-review-queue.jsonl  (one JSON object per broken SKU)
//   { dw_sku, bad_mfr, vendor, pattern, best_guess_real_mfr, source, confidence }
//   source ∈ { dwsku-alpha-prefix, filemaker-note, vendor-feed, none }
import { execFileSync } from 'node:child_process';
import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PSQL = process.env.PSQL_BIN || '/opt/homebrew/opt/postgresql@14/bin/psql';
const OUT = join(ROOT, 'data', 'mfr-review-queue.jsonl');

// ---- args -------------------------------------------------------------------------------
const args = process.argv.slice(2);
const flag = (n) => args.includes(n);
const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
const LIMIT = parseInt(opt('--limit', '50'), 10);
const USE_FM = !flag('--no-fm');

// DEFAULT Tier-3 vendor set — EXCLUDING the Schumacher family (DW# IS their real mfr) and the
// pure-numeric-legit vendors. These are confirmed to carry the DW#==mfr signature.
const DEFAULT_VENDORS = [
  'Glitter Walls', 'Hollywood Wallcoverings', 'LA Walls', 'DW Bespoke Studio',
  'Jeffrey Stevens', 'Graham & Brown', 'Ralph Lauren', 'Anna French',
  'Wolf Gordon', 'Missoni', 'IKSEL', 'Versace',
];
const VENDORS = opt('--vendors', '') ? opt('--vendors', '').split(',').map((v) => v.trim()).filter(Boolean) : DEFAULT_VENDORS;

// ---- READ-ONLY psql helpers -------------------------------------------------------------
const sqlEsc = (v) => String(v ?? '').replace(/'/g, "''");
function sqlRows(q) {
  // -F $'\t' tab-separated, -A unaligned, -t tuples-only. Purely a SELECT — read-only.
  try {
    const out = execFileSync(PSQL, ['dw_unified', '-tAF', '\t', '-c', q], { encoding: 'utf8' });
    return out.split('\n').filter(Boolean).map((l) => l.split('\t'));
  } catch (e) { console.error('psql read failed:', e.message); return []; }
}

// ---- guard helpers (same semantics as lib/wallpaper.js — pure, no writes) ----------------
const dwTail = (dwSku) => (String(dwSku || '').match(/(\d+)(?:[-_ ]?sample)?$/i) || [])[1] || '';
// LEGIT-NUMERIC allowlist — DW# IS the real mfr for these (Schumacher DWSW). NEVER queue them
// for "repair": their DW#==mfr rows are correct, not corrupt. Belt-and-suspenders even if a
// user explicitly passes --vendors "Schumacher" (the guard in lib/wallpaper.js already
// protects the live importer; this keeps the queue from proposing a wrong repair).
const LEGIT_NUMERIC_MFR_PREFIXES = ['DWSW', 'SCH']; // Schumacher family — DW# IS the real mfr
const isLegitNumericLine = (dwSku, vendor) => {
  const up = String(dwSku || '').toUpperCase();
  if (/schumacher/i.test(String(vendor || ''))) return true;               // any Schumacher vendor variant
  return LEGIT_NUMERIC_MFR_PREFIXES.some((pre) => up === pre || up.startsWith(pre + '-') || new RegExp(`^${pre}\\d`).test(up));
};
function recoverAlphaMfr(dwSku, badMfr) {
  const bad = String(badMfr || '').trim();
  if (!bad || !/^\d+$/.test(bad)) return '';
  const seg = String(dwSku || '').replace(/[-_ ]?sample$/i, '').split(/[-_ ]/).pop() || '';
  const m = seg.match(/^([A-Za-z]+)(\d+)$/);
  if (m && m[2] === bad) return (m[1] + m[2]).toUpperCase();
  return '';
}

// ---- optional READ-ONLY FileMaker note lookup -------------------------------------------
// Some real codes live only on the FileMaker WALLPAPER master (the "Mfr #<code>" note /
// Mfr Pattern field) and never made it back into dw_unified. We do a READ-ONLY find for the
// master and read its Mfr Pattern / MetDataSearchWord — but ONLY report it as a guess when
// it differs from the DW# placeholder. NEVER writes.
let fm = null;
if (USE_FM) {
  try {
    // load .env for FM creds (same pattern as the repo's other scripts)
    if (existsSync(join(ROOT, '.env'))) {
      for (const l of readFileSync(join(ROOT, '.env'), 'utf8').split('\n')) {
        const m = l.match(/^([A-Z0-9_]+)=(.*)$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
      }
    }
    fm = await import('../src/fm-client.js');
    await fm.ping();
  } catch (e) {
    console.error(`FileMaker unavailable (${e.message.split('\n')[0]}) — continuing with PG-only sources.`);
    fm = null;
  }
}

const FULL = '*List Wallpapers - Full View';
const normSku = (s) => String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
async function fmNoteMfr(dwSku) {
  if (!fm) return '';
  // Split dw_sku into Series + pattern-segment (best-effort) and find the master READ-ONLY.
  const clean = String(dwSku).replace(/[-_ ]?sample$/i, '');
  const parts = clean.split(/[-_ ]/);
  const series = parts.length > 1 ? parts[0] : '';
  const pattern = parts.length > 1 ? parts.slice(1).join('-') : clean;
  const q = series ? [{ Series: '==' + series, 'JS Pattern': '==' + pattern }] : [{ 'JS Pattern': '==' + pattern }];
  try {
    const r = await fm.findRecords('WALLPAPER', FULL, q, { limit: 3 });
    for (const rec of (r.records || [])) {
      const fd = rec.fieldData || {};
      // confirm same SKU
      if (normSku(String(fd.Series || '') + String(fd['JS Pattern'] || '')) !== normSku(clean)) continue;
      const real = String(fd['Mfr Pattern'] || fd['Detail 1 Mfr Number'] || fd.MetDataSearchWord || '').trim();
      // Only a guess if it's a REAL code that differs from the DW# numeric tail.
      if (real && real !== dwTail(dwSku)) return real;
    }
  } catch { /* no-match / auth blip -> no guess */ }
  return '';
}

// ---- gather the broken rows per vendor --------------------------------------------------
// Broken (DW#==mfr) = mfr_sku equals the DW SKU's numeric tail. Read-only SELECT.
mkdirSync(join(ROOT, 'data'), { recursive: true });
const rows = [];
const perVendor = {};
// Distribute the row budget across vendors so the sample spans the whole set (round up),
// rather than exhausting the cap on the first vendor.
const perVendorCap = Math.max(1, Math.ceil(LIMIT / VENDORS.length));
for (const vendor of VENDORS) {
  if (rows.length >= LIMIT) break;
  const cap = Math.min(perVendorCap, LIMIT - rows.length);
  const q = `SELECT dw_sku, mfr_sku, vendor, COALESCE(pattern_name,'') FROM shopify_products
    WHERE vendor ILIKE '%${sqlEsc(vendor)}%'
      AND mfr_sku IS NOT NULL AND mfr_sku <> ''
      AND mfr_sku = (regexp_match(dw_sku, '([0-9]+)(-[Ss]ample)?$'))[1]
    ORDER BY dw_sku
    LIMIT ${cap}`;
  const found = sqlRows(q);
  perVendor[vendor] = { total: found.length, alpha: 0, fmnote: 0, none: 0 };
  for (const [dw_sku, bad_mfr, vend, pattern] of found) {
    if (rows.length >= LIMIT) break;
    // Skip legit-numeric lines (Schumacher family): DW#==mfr is correct there, not corrupt.
    if (isLegitNumericLine(dw_sku, vend || vendor)) { perVendor[vendor].total--; continue; }
    // Source 1 (Tier-2, HIGH conf): dw_sku alpha-prefix recovery.
    let best = recoverAlphaMfr(dw_sku, bad_mfr), source = best ? 'dwsku-alpha-prefix' : '', confidence = best ? 'high' : '';
    // Source 2 (MEDIUM conf): READ-ONLY FileMaker Mfr# note on the master.
    if (!best) {
      const note = await fmNoteMfr(dw_sku);
      if (note) { best = note; source = 'filemaker-note'; confidence = 'medium'; }
    }
    // else: none — needs a vendor re-scrape.
    if (!best) { source = 'none'; confidence = 'none'; }
    perVendor[vend || vendor] = perVendor[vendor];
    perVendor[vendor][source === 'dwsku-alpha-prefix' ? 'alpha' : source === 'filemaker-note' ? 'fmnote' : 'none']++;
    rows.push({ dw_sku, bad_mfr, vendor: vend || vendor, pattern, best_guess_real_mfr: best || null, source, confidence });
  }
}

// ---- write the queue (the ONLY write this tool makes — to its own data file) -------------
writeFileSync(OUT, rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''), 'utf8');

// ---- report -----------------------------------------------------------------------------
console.log(`mfr-review-queue: ${rows.length} broken rows across ${VENDORS.length} vendors -> ${OUT}`);
console.log(`FileMaker note lookups: ${fm ? 'ON' : 'OFF (PG-only)'}\n`);
console.log('per-vendor quality:');
for (const v of VENDORS) {
  const s = perVendor[v]; if (!s) continue;
  console.log(`  ${v.padEnd(24)}  rows=${String(s.total).padStart(3)}  alpha=${s.alpha}  fm-note=${s.fmnote}  none=${s.none}`);
}
const bySource = rows.reduce((a, r) => { a[r.source] = (a[r.source] || 0) + 1; return a; }, {});
console.log('\nsample (first 12):');
for (const r of rows.slice(0, 12)) {
  console.log(`  ${r.dw_sku.padEnd(18)} bad=${String(r.bad_mfr).padEnd(8)} -> ${String(r.best_guess_real_mfr || '(none)').padEnd(12)} [${r.source}/${r.confidence}] "${r.pattern.slice(0, 30)}"`);
}
console.log('\nby source:', JSON.stringify(bySource));
console.log('\nZERO writes to dw_unified / FileMaker / Shopify. Confirm the vendor set + guesses, then run the (Steve-gated) repair separately.');