← back to Filemaker Mcp
lib/wallpaper.js
670 lines
// Ensure a complete FileMaker WALLPAPER master record exists for a DW SKU, sourced
// from dw_unified, so an invoice line's lookup fields (width/name/vid/supplier/mfr#)
// resolve. Steve's rule (2026-07-10): Mfr Pattern MUST hold a real mfr number and
// vid MUST be the vendor's code; a SKU with no findable mfr number is FLAGGED, not
// created. See memory filemaker-wallpaper-record-for-invoice.
//
// The invoice's width/supplier/name resolve LIVE once this record exists; VID and
// Detail 1 Mfr Number are COPY-lookups, so the caller writes them onto the line.
import { execFileSync } from 'node:child_process';
import * as fm from '../src/fm-client.js';
const ENTRY = 'Add wallcovering';
const FULL = '*List Wallpapers - Full View';
const PSQL = process.env.PSQL_BIN || '/opt/homebrew/opt/postgresql@14/bin/psql';
function sql(q) {
try { return JSON.parse(execFileSync(PSQL, ['dw_unified', '-tAc', q], { encoding: 'utf8' }).trim() || 'null'); }
catch { return null; }
}
// Escape a value for a single-quoted SQL literal.
const sqlEsc = (v) => String(v ?? '').replace(/'/g, "''");
// Run a non-SELECT statement (DDL / INSERT / UPDATE / DELETE). Returns trimmed stdout
// (e.g. a RETURNING value) or null on error. Kept separate from sql() which JSON-parses.
function sqlExec(q) {
// -q (quiet) is REQUIRED: without it psql writes the command-status tag ("INSERT 0 0")
// to stdout, which a no-op ON CONFLICT would return as a non-empty string and be
// misread as "claim won" — the RETURNING contract only holds when the tag is suppressed.
try { return execFileSync(PSQL, ['dw_unified', '-qtAc', q], { encoding: 'utf8' }).trim(); }
catch { return null; }
}
// ---- LOCAL IDEMPOTENCY LEDGER (duplicate-master prevention) -----------------------------
// FileMaker Cloud does NOT index a freshly-created record for finds for a short-but-nonzero
// window, so a re-run (or a concurrent run) whose existence check (findExistingMaster) runs
// inside that window misses the just-created master and mints a SECOND one — the verified
// duplicate pattern (every dup pair shares an identical Series|JS-Pattern and both records
// are fully populated => a genuine double-create, not a partial/split bug). A Postgres row
// is IMMEDIATELY consistent where FileMaker's index lags, so we record every master WE
// create keyed on the normalized SKU and consult it BEFORE creating. The UNIQUE PK also
// serializes two concurrent creators (only one wins the claim; the loser skips the create).
let _ledgerReady = false;
function ledgerInit() {
if (_ledgerReady) return;
sqlExec(`CREATE TABLE IF NOT EXISTS wallpaper_master_ledger (
norm_key text PRIMARY KEY, series text, js_pattern text, fm_record_id text,
mfr text, vid text, created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now())`);
_ledgerReady = true;
}
// The FileMaker recordId we've already recorded for this normalized key (only once the
// create actually succeeded), or '' if none.
function ledgerLookup(normKey) {
ledgerInit();
const row = sql(`SELECT json_build_object('id', fm_record_id) FROM wallpaper_master_ledger WHERE norm_key='${sqlEsc(normKey)}' AND fm_record_id IS NOT NULL`);
return (row && row.id) ? String(row.id) : '';
}
// Atomically CLAIM the key before creating in FileMaker. TRUE => we own the claim (proceed
// to create); FALSE => a live peer already holds it (skip the create, no duplicate). A
// prior claim that never recorded an fm_record_id and is >5 min old is treated as abandoned
// (a create that died) and may be reclaimed.
function ledgerClaim(normKey, series, jsPattern) {
ledgerInit();
const out = sqlExec(`INSERT INTO wallpaper_master_ledger(norm_key,series,js_pattern)
VALUES('${sqlEsc(normKey)}','${sqlEsc(series)}','${sqlEsc(jsPattern)}')
ON CONFLICT(norm_key) DO UPDATE SET series=EXCLUDED.series, js_pattern=EXCLUDED.js_pattern, updated_at=now()
WHERE wallpaper_master_ledger.fm_record_id IS NULL AND wallpaper_master_ledger.updated_at < now() - interval '5 minutes'
RETURNING norm_key`);
return !!(out && out.length);
}
// Record the FileMaker recordId (+ mfr/vid) once the master is actually created.
function ledgerRecord(normKey, id, mfr, vid) {
ledgerInit();
sqlExec(`UPDATE wallpaper_master_ledger SET fm_record_id='${sqlEsc(id)}', mfr='${sqlEsc(mfr)}', vid='${sqlEsc(vid)}', updated_at=now() WHERE norm_key='${sqlEsc(normKey)}'`);
}
// Release a claim whose create FAILED, so a later run can retry instead of dead-locking.
function ledgerRelease(normKey) {
sqlExec(`DELETE FROM wallpaper_master_ledger WHERE norm_key='${sqlEsc(normKey)}' AND fm_record_id IS NULL`);
}
const firstNum = (s) => { const m = String(s || '').match(/[\d.]+/); return m ? m[0] : ''; };
const widthClean = (s) => { const n = firstNum(s); return n ? `${n}"` : ''; };
// Canonical SKU key: UPPERCASE + strip ALL non-alphanumerics. Collapses the three
// inconsistent forms of a SKU into one comparable key so a lookup can't miss (and can't
// mint a duplicate master) just because separators differ across the invoice/calc/raw
// forms. Digits are PRESERVED, so numeric-suffix SKUs stay distinct:
// OPARTDECOWAVES / OP-ARTDECO-WAVES / OP-ART-DECO-WAVES -> OPARTDECOWAVES
// SCH-12345 -> SCH12345 ; SCH-12346 -> SCH12346 (still distinct)
const normalizeSku = (s) => String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
// KNOWN DW series prefixes whose "split at the first separator" is trustworthy. Used by
// parseCombo to decide `confident`. These are canonical DW series codes (DW + 2 letters,
// plus the cork aliases and a few legacy). A leading token NOT in this set means the
// Series|remainder boundary is a GUESS (e.g. OP-ART where the real series is `OP-ART`,
// not `OP`) and we must fail closed rather than mint a master off the guess. The set is
// intentionally conservative: unknown-but-real series still resolve through the
// component-field OR-find (which enumerates every split) — this gate only governs whether
// a genuinely-new SKU may be CREATED.
const KNOWN_SERIES = new Set([
'CORK', 'DWGL', 'DWLG',
'DWC', 'DWSW', 'DWWC', 'DWLK', 'DWJS', 'DWBR', 'DWPH', 'DWPP', 'DWAT', 'DWAF', 'DWKK',
]);
// Authoritative Series -> vid (vendor code) overrides for PRIVATE-LABEL series whose real
// vendor is hidden behind a DW house brand in dw_unified (so vid can't be derived from the
// source row) AND whose existing FileMaker masters carry a WRONG vid that the modal-vote
// vidForSeries() would otherwise perpetuate. Checked BEFORE an existing master's own vid so
// re-running corrects a previously-mis-stamped master. DWPP ("Phillipe Romano" London Paper
// Weave / Prague Samarra etc.) is really MDC Wallcoverings — vid MDCKEN, NOT Wolf Gordon
// ("wol"), which is what the 4 mislabeled DWPP masters had. (Steve, 2026-08-12.)
const SERIES_VID = { DWPP: 'MDCKEN' };
// combo sku (DWKK134979, OP-ART-DECO-WAVES) -> {prefix, num, dashSku, key, confident}
// prefix = the leading DW series letters; num = the rest (pattern remainder, which may
// itself be all-alpha and hyphenated, e.g. "ART-DECO-WAVES"). key = the normalized
// canonical form used for duplicate-safe master matching.
//
// `confident` (TK-10083 iter 2): TRUE only when the Series|remainder split is genuinely
// DERIVABLE from the string alone — i.e. classic letters-then-digits (DWKK134979) or a
// KNOWN series prefix. It is FALSE for the all-alpha, no-separator invoice form
// (OPARTDECOWAVES) AND for an all-ALPHA hyphenated compound (OP-ART-DECO-WAVES): in a
// FileMaker master the Series is `OP-ART` and the JS Pattern is `DECO-WAVES`, a split
// that is NOT recoverable by string rules (both OP|ART-DECO-WAVES and OP-ART-DECO|WAVES
// are equally plausible). Minting a master off a guessed split is exactly what created
// the duplicates 538697/538698 (Series `DWC`, wrong split). When confident===false the
// caller MUST NOT create — it fails closed to review (see resolveWallpaperSource).
function parseCombo(combo) {
const raw = String(combo || '').trim();
if (!raw) return null;
let prefix, num, confident;
// 1) Classic letters-then-digits, no separator: DWKK134979 -> prefix DWKK, num 134979.
// The alpha/digit boundary is unambiguous, so this split IS confident.
let m = raw.match(/^([A-Za-z]+)(\d.*)$/);
if (m) { prefix = m[1]; num = m[2]; confident = true; }
// 2) Separator-delimited with a KNOWN series prefix before the first '-'/'_'/space,
// e.g. DWGL-830880 or CORK-12 or DWC-OP-ART... — the leading token is a real DW
// series code we recognize, so the split is confident.
else if ((m = raw.match(/^([A-Za-z]+)[-_ ](.+)$/)) && KNOWN_SERIES.has(m[1].toUpperCase())) {
prefix = m[1]; num = m[2]; confident = true;
}
// 3) Separator-delimited but the leading token is NOT a known series — we CANNOT trust
// the naive "split at first dash" (that is what mis-split OP-ART-DECO-WAVES into
// OP|ART-DECO-WAVES). Keep a best-guess prefix for the series-vid fallback ONLY, but
// mark it NOT confident so no create happens off this guess.
else if ((m = raw.match(/^([A-Za-z]+)[-_ ](.+)$/))) { prefix = m[1]; num = m[2]; confident = false; }
// 4) All-alpha, no separator (invoice form OPARTDECOWAVES): no derivable split at all.
else { prefix = ''; num = raw; confident = false; }
prefix = prefix.toUpperCase();
const dashSku = prefix ? `${prefix}-${num}` : num;
return { prefix, num, dashSku, raw: raw.toUpperCase(), key: normalizeSku(raw), confident };
}
// Series that are the SAME physical line under different DW prefixes (renamed over
// time — e.g. Greenland cork bounced Cork<->DWGL<->DWLG). `combo sku` = Series & JS
// Pattern is a FileMaker CALC, so a renamed master hides from a current-prefix "=="
// lookup and a duplicate master gets minted. Check these aliases before creating.
const SERIES_ALIASES = { CORK: ['DWGL', 'DWLG'], DWGL: ['CORK', 'DWLG'], DWLG: ['CORK', 'DWGL'] };
// FileMaker codes that mean "this query found nothing / doesn't apply" (try the next
// query) — as opposed to a REAL error (auth/timeout/lock) that means existence is
// UNVERIFIABLE and we must NOT create. Swallowing a real error as "not found" is what
// minted duplicates. 401=no match, 102=field not on layout, 105=layout, 106=table.
const FM_SKIP_CODES = new Set(['401', '102', '105', '106']);
// Enumerate every candidate {Series, JS Pattern} split of a dashed SKU form. The FM
// master stores the SKU pre-split across two REAL, findable fields (Series + JS Pattern),
// concatenated with NO separator into the `combo sku` calc. We cannot know the split from
// the string (OP-ART|DECO-WAVES vs OP|ART-DECO-WAVES), so we generate a split at EVERY
// separator boundary and let FileMaker's server-side component find pick the real one.
// "OP-ART-DECO-WAVES" -> {OP,ART-DECO-WAVES},{OP-ART,DECO-WAVES},{OP-ART-DECO,WAVES}
// The normalized re-check on returned records rejects any wrong split that happens to hit.
function splitCandidates(dashForm) {
const s = String(dashForm || '').trim();
const out = [];
const idxs = [];
for (let i = 0; i < s.length; i++) if (/[-_ ]/.test(s[i])) idxs.push(i);
for (const i of idxs) {
const series = s.slice(0, i);
const pattern = s.slice(i + 1);
if (series && pattern) out.push([series, pattern]);
}
return out;
}
// Read the REAL spec off a matched WALLPAPER master's fieldData (READ-ONLY). The master
// stores the authoritative human-entered mfr number in `Mfr Pattern` (its `Detail 1 Mfr
// Number` is a copy-lookup of the same). We return every field the caller might use to
// COMPLETE an invoice line from the master it already found — so an existing master is
// never re-flagged as "no mfr number" (TK-10906, deliverable 1a). Blank -> undefined.
function masterFields(fd) {
const s = (v) => { const t = String(v ?? '').trim(); return t || undefined; };
return {
mfr: s(fd['Mfr Pattern']) || s(fd['Detail 1 Mfr Number']),
vid: s(fd.vid),
name: s(fd['Name of Pattern']),
color: s(fd['Color of Pattern']),
width: s(fd.Width),
content: s(fd.Content),
repeat: s(fd.Repeat),
supplier: s(fd.Supplier),
};
}
// Alias- and error-aware existence check for a WALLPAPER master.
// Returns { id, vid, err, master }: id/vid of the matching master (vid = its OWN stored
// code), master = the real spec read off that record (see masterFields — used to complete
// a line WITHOUT re-flagging, TK-10906), or err = a real (non-skip) FileMaker code meaning
// existence could not be verified.
//
// SEPARATOR-AGNOSTIC MATCH (TK-10083, iter 2): the same SKU appears in THREE inconsistent
// forms — invoice OPARTDECOWAVES, FileMaker calc OP-ARTDECO-WAVES, raw OP-ART-DECO-WAVES.
// `combo sku` is a STORED CALC (= Series & JS Pattern, no separator); FileMaker returns
// 401 on a wildcard find against it and a "==" matches ONLY the byte-for-byte stored
// value — which we CANNOT reconstruct because the Series|JS-Pattern split is not derivable
// from the string (proven live: ==OPARTDECOWAVES and ==OP-ART-DECO-WAVES both 401; only
// the exact stored ==OP-ARTDECO-WAVES matches). Iter 1 relied on those calc "==" probes
// and therefore silently missed the master -> minted a duplicate.
//
// The robust, server-side-correct path is the COMPONENT FIELDS: Series and JS Pattern are
// real indexed text fields, findable with "==". We recover the canonical DASHED form from
// dw_unified (Postgres normalizes all three forms back to the stored dw_sku, e.g.
// OP-ART-DECO-WAVES), enumerate every dash-boundary split into {Series,JS Pattern}, issue
// ONE _find with the OR-array of candidate splits, and accept a returned record only when
// normalizeSku(Series + JS Pattern) === the target key. The OR-find matches whichever
// split is real (here OP-ART|DECO-WAVES -> 240939); wrong splits match nothing.
async function findExistingMaster(combo, p, dashCanonical) {
const prefixes = [p.prefix, ...(SERIES_ALIASES[p.prefix] || [])].filter(Boolean);
// Target normalized keys we accept a match against: the parsed key AND per-alias-prefix
// rewrites (a renamed series stores a different prefix but the same pattern remainder).
const targetKeys = new Set([p.key]);
for (const pre of prefixes) targetKeys.add(normalizeSku(pre + p.num));
// ---- Pass A: cheap exact calc "==" probes (fast path for classic letters-then-digits
// SKUs whose stored calc form IS reconstructable). These are a best-effort accelerator;
// Pass B is the correctness guarantee. Only probe the field that exists (`combo sku`).
const forms = new Set();
const add = (v) => { if (v) forms.add(v); };
add(combo); add(p.dashSku); add(p.raw);
if (dashCanonical) add(dashCanonical);
for (const pre of prefixes) { add(pre + p.num); add(pre + '-' + p.num); }
for (const v of forms) {
try {
const r = await fm.findRecords('WALLPAPER', FULL, { 'combo sku': '==' + v }, { limit: 5 });
for (const rec of (r.records || [])) {
const fd = rec.fieldData || {};
if (targetKeys.has(normalizeSku(fd['combo sku']))) {
return { id: rec.recordId, vid: (fd.vid || '').trim(), err: null, master: masterFields(fd) };
}
}
} catch (e) {
const code = String(e.fmCode || '');
if (FM_SKIP_CODES.has(code)) continue; // no-match / field not on layout -> next probe
return { id: null, vid: '', err: code || e.message }; // real error -> UNVERIFIABLE
}
}
// ---- Pass B (correctness): component-field {Series==X, JS Pattern==Y} OR-find over
// every dash-boundary split of the canonical dashed form (from Postgres) AND the raw
// incoming string. This is the path that resolves OP-ART-DECO-WAVES -> 240939.
const splitSources = [];
if (dashCanonical) splitSources.push(dashCanonical);
splitSources.push(p.raw, p.dashSku, combo);
const seen = new Set();
const orQuery = [];
for (const src of splitSources) {
for (const [series, pattern] of splitCandidates(src)) {
const k = series.toUpperCase() + '