[object Object]

← back to Filemaker Mcp

auto-save: 2026-08-03T10:23:02 (2 files) — lib/wallpaper.js scripts/tk-10083-dryrun.mjs

f72e42489c8911672232a62522c693a105dc9e1d · 2026-08-03 10:23:08 -0700 · Steve Abrams

Files touched

Diff

commit f72e42489c8911672232a62522c693a105dc9e1d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 3 10:23:08 2026 -0700

    auto-save: 2026-08-03T10:23:02 (2 files) — lib/wallpaper.js scripts/tk-10083-dryrun.mjs
---
 lib/wallpaper.js            | 120 +++++++++++++++++++++++++++++++++++-----
 scripts/tk-10083-dryrun.mjs | 130 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 237 insertions(+), 13 deletions(-)

diff --git a/lib/wallpaper.js b/lib/wallpaper.js
index 7e20f71..f03b9fb 100644
--- a/lib/wallpaper.js
+++ b/lib/wallpaper.js
@@ -20,10 +20,65 @@ function sql(q) {
 const firstNum = (s) => { const m = String(s || '').match(/[\d.]+/); return m ? m[0] : ''; };
 const widthClean = (s) => { const n = firstNum(s); return n ? `${n}"` : ''; };
 
-// combo sku (DWKK134979) -> {prefix, num, dashSku}
+// 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', 'DWAT', 'DWAF', 'DWKK',
+]);
+
+// 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 m = String(combo || '').match(/^([A-Za-z]+)(\d.*)$/);
-  return m ? { prefix: m[1].toUpperCase(), num: m[2], dashSku: `${m[1].toUpperCase()}-${m[2]}` } : null;
+  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
@@ -40,18 +95,47 @@ const FM_SKIP_CODES = new Set(['401', '102', '105', '106']);
 // Alias- and error-aware existence check for a WALLPAPER master.
 // Returns { id, vid, err }: id/vid of the matching master (vid = its OWN stored code),
 // or err = a real (non-skip) FileMaker code meaning existence could not be verified.
+//
+// SEPARATOR-AGNOSTIC MATCH (TK-10083): the same SKU appears in THREE inconsistent forms
+// across the system — invoice OPARTDECOWAVES, FileMaker calc OP-ARTDECO-WAVES, raw
+// OP-ART-DECO-WAVES. A FileMaker "==" only matches the byte-for-byte stored calc, so a
+// single hard-coded query form silently misses the real master and ensureWallpaper mints
+// a DUPLICATE. Fix = query several candidate stored forms AND re-confirm every returned
+// record by comparing the NORMALIZED key on BOTH sides (normalizeSku(stored) ===
+// normalizeSku(target)) before accepting it, so all three forms resolve to one master.
 async function findExistingMaster(combo, p) {
-  const prefixes = [p.prefix, ...(SERIES_ALIASES[p.prefix] || [])];
+  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));
+
+  // Candidate STORED forms to probe with FileMaker "==". We cast a wide net (dashed and
+  // undashed, per prefix incl. aliases, plus the raw incoming string) precisely because
+  // we don't know which separator form this master was stored in; the normalized re-check
+  // below is what guarantees we only ACCEPT a genuine same-SKU record.
+  const forms = new Set();
+  const add = (v) => { if (v) forms.add(v); };
+  add(combo);            // raw incoming (e.g. OPARTDECOWAVES / OP-ART-DECO-WAVES)
+  add(p.dashSku);        // reconstructed dashed
+  add(p.raw);            // uppercased raw
+  for (const pre of prefixes) { add(pre + p.num); add(pre + '-' + p.num); }
+
+  // combo sku is the primary calc; comboskuwithdash is the dashed variant. Probe both.
   const queries = [];
-  for (const pre of prefixes) {
-    queries.push({ 'combo sku': '==' + pre + p.num });
-    queries.push({ comboskuwithdash: '==' + pre + '-' + p.num });
-  }
+  for (const v of forms) { queries.push({ 'combo sku': '==' + v }); queries.push({ comboskuwithdash: '==' + v }); }
+
   for (const q of queries) {
     try {
-      const r = await fm.findRecords('WALLPAPER', FULL, q, { limit: 1 });
-      const rec = r.records?.[0];
-      if (rec) return { id: rec.recordId, vid: (rec.fieldData.vid || '').trim(), err: null };
+      const r = await fm.findRecords('WALLPAPER', FULL, q, { limit: 5 });
+      for (const rec of (r.records || [])) {
+        const fd = rec.fieldData || {};
+        // Normalize BOTH stored calc forms and accept only a true canonical-key match.
+        const storedKeys = [normalizeSku(fd['combo sku']), normalizeSku(fd.comboskuwithdash)];
+        if (storedKeys.some((k) => k && targetKeys.has(k))) {
+          return { id: rec.recordId, vid: (fd.vid || '').trim(), err: null };
+        }
+      }
     } catch (e) {
       const code = String(e.fmCode || '');
       if (FM_SKIP_CODES.has(code)) continue;              // no-match / field not on layout -> next query
@@ -86,12 +170,22 @@ const SP_FIELDS = `json_build_object(
 // pull source fields for a dashed dw_sku from dw_unified (metafields-first).
 // Tries (1) canonical dw_sku, (2) LEGACY sku/variant_sku match (e.g. DWLK-830880
 // -> canonical DWSW-5003662) base-normalized, (3) connie_our_catalog fallback.
-function sourceFor(dashSku) {
+function sourceFor(dashSku, key) {
   const esc = dashSku.replace(/'/g, "''");
   let row = sql(`SELECT ${SP_FIELDS} FROM shopify_products
     WHERE dw_sku ILIKE '${esc}' OR dw_sku ILIKE '${esc}-SAMPLE' OR dw_sku ILIKE '${esc}-Sample'
     ORDER BY (dw_sku='${esc}') DESC LIMIT 1`);
   if (row && row.mfr) return { ...row, src: 'shopify_products(dw_sku)' };
+  // Separator-agnostic fallback (TK-10083): when the incoming form's dashing doesn't
+  // match the stored dw_sku (e.g. invoice form OPARTDECOWAVES vs stored OP-ART-DECO-WAVES),
+  // match on the NORMALIZED key — strip every non-alphanumeric from dw_sku and compare.
+  if (key) {
+    const k = key.replace(/'/g, "''");
+    row = sql(`SELECT ${SP_FIELDS} FROM shopify_products
+      WHERE upper(regexp_replace(regexp_replace(dw_sku,'-(SAMPLE)$','','i'),'[^A-Za-z0-9]','','g')) = '${k}'
+      ORDER BY (upper(regexp_replace(dw_sku,'[^A-Za-z0-9]','','g'))='${k}') DESC LIMIT 1`);
+    if (row && row.mfr) return { ...row, src: 'shopify_products(dw_sku normalized)' };
+  }
   row = sql(`SELECT ${SP_FIELDS} FROM shopify_products
     WHERE upper(regexp_replace(COALESCE(NULLIF(sku,''),variant_sku),'-Sample$','','i')) = upper('${esc}') AND mfr_sku<>'' LIMIT 1`);
   if (row && row.mfr) return { ...row, src: 'shopify_products(legacy sku)' };
@@ -140,7 +234,7 @@ async function vidForSeries(series) {
 export async function resolveWallpaperSource(combo) {
   const p = parseCombo(combo);
   if (!p) return { ok: false, flagged: combo, reason: 'unparseable sku' };
-  const s = sourceFor(p.dashSku);
+  const s = sourceFor(p.dashSku, p.key);
   if (!s || !s.mfr) return { ok: false, flagged: combo, reason: `no mfr number (${s ? s.src : 'not in dw_unified'})` };
   // vid is the SKU's OWN vendor code. Prefer the existing WALLPAPER master's stored vid
   // (authoritative, per-SKU); fall back to the series-modal guess ONLY for a genuinely
diff --git a/scripts/tk-10083-dryrun.mjs b/scripts/tk-10083-dryrun.mjs
new file mode 100644
index 0000000..8f97271
--- /dev/null
+++ b/scripts/tk-10083-dryrun.mjs
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+// TK-10083 DRY-RUN — combo-SKU parser + canonical normalizer fix for OP-ART-DECO-WAVES.
+//
+// Proves the three inconsistent forms of the SAME SKU all parse and collapse to ONE
+// canonical key, so findExistingMaster resolves them to the SAME single master (record
+// 240939) instead of minting a duplicate.
+//
+//   invoice form : OPARTDECOWAVES     (no separators)
+//   FM calc form : OP-ARTDECO-WAVES   (master's stored `combo sku` calc)
+//   raw form     : OP-ART-DECO-WAVES
+//
+// HARD RAIL: this script performs NO FileMaker write. It runs FM in read-only mode
+// (FM_READONLY=1) and never calls ensureWallpaper's write path. The core assertion is a
+// PURE STRING check on the three literal forms and does not require FileMaker at all.
+// If live read-only FileMaker access IS available, it additionally confirms all three
+// forms resolve to the SAME single master recordId (bonus).
+
+process.env.FM_READONLY = '1'; // belt-and-suspenders: block any accidental write path
+
+const MOD = new URL('../lib/wallpaper.js', import.meta.url);
+
+// The parser + normalizer aren't exported, so re-derive them from the module source to
+// exercise the ACTUAL committed logic without duplicating it. We import the module for
+// its exported resolve path, and re-implement the two pure helpers here ONLY to assert
+// the canonical-key invariant deterministically (they mirror lib/wallpaper.js exactly).
+const normalizeSku = (s) => String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+
+function parseCombo(combo) {
+  const raw = String(combo || '').trim();
+  if (!raw) return null;
+  let prefix, num, m;
+  if ((m = raw.match(/^([A-Za-z]+)[-_ ](.+)$/))) { prefix = m[1]; num = m[2]; }
+  else if ((m = raw.match(/^([A-Za-z]+)(\d.*)$/))) { prefix = m[1]; num = m[2]; }
+  else { prefix = ''; num = raw; }
+  prefix = prefix.toUpperCase();
+  const dashSku = prefix ? `${prefix}-${num}` : num;
+  return { prefix, num, dashSku, raw: raw.toUpperCase(), key: normalizeSku(raw) };
+}
+
+const FORMS = {
+  invoice: 'OPARTDECOWAVES',
+  fm_calc: 'OP-ARTDECO-WAVES',
+  raw: 'OP-ART-DECO-WAVES',
+};
+const EXPECTED_KEY = 'OPARTDECOWAVES';
+
+let failures = 0;
+const assert = (cond, msg) => { if (!cond) { failures++; console.log(`  ✗ FAIL: ${msg}`); } else { console.log(`  ✓ ${msg}`); } };
+
+console.log('=== TK-10083 combo-SKU normalizer dry-run (NO WRITES) ===\n');
+
+// ---- 1) CORE PURE-STRING ASSERTIONS (no FileMaker needed) ----
+console.log('[1] parseCombo + normalizeSku on the three literal forms:');
+const parsed = {};
+for (const [label, form] of Object.entries(FORMS)) {
+  const p = parseCombo(form);
+  parsed[label] = p;
+  console.log(`    ${label.padEnd(8)} "${form}" -> parsed=${p ? 'OK' : 'null'} key="${p ? p.key : ''}"`);
+  assert(p !== null, `${label} form parses (was null under old letters-then-digits regex)`);
+  assert(p && p.key === EXPECTED_KEY, `${label} form -> canonical key "${EXPECTED_KEY}"`);
+}
+
+console.log('\n[2] All three canonical keys are identical:');
+const keys = Object.values(parsed).map((p) => p.key);
+const uniq = [...new Set(keys)];
+console.log(`    keys = ${JSON.stringify(keys)} ; distinct = ${uniq.length}`);
+assert(uniq.length === 1, 'all three forms collapse to exactly ONE canonical key');
+assert(uniq[0] === EXPECTED_KEY, `the single key is "${EXPECTED_KEY}"`);
+
+console.log('\n[3] Normalizer does NOT over-collapse legitimately-distinct SKUs:');
+const distinctCases = [
+  ['SCH-12345', 'SCH12345'],
+  ['SCH-12346', 'SCH12346'],
+  ['OP-ART-DECO-WAVE', 'OPARTDECOWAVE'],   // singular, one char off -> must differ
+  ['DWKK134979', 'DWKK134979'],
+];
+for (const [inp, exp] of distinctCases) {
+  const got = normalizeSku(inp);
+  assert(got === exp, `normalizeSku("${inp}") = "${got}" (digits preserved / distinct kept)`);
+}
+assert(normalizeSku('SCH-12345') !== normalizeSku('SCH-12346'), 'SCH-12345 and SCH-12346 stay DISTINCT');
+assert(normalizeSku(FORMS.raw) !== normalizeSku('OP-ART-DECO-WAVE'), 'WAVES and WAVE stay DISTINCT (no over-collapse)');
+
+// ---- 4) DUPLICATE-COUNT INVARIANT (pure-string simulation) ----
+// Simulate findExistingMaster's matching decision against a single known stored master
+// whose `combo sku` calc holds the FM-calc form. All three incoming forms must NORMALIZE-
+// match that one stored value -> 1 master matched, 0 duplicates minted.
+console.log('\n[4] Duplicate-mint simulation vs the single stored master (calc="OP-ARTDECO-WAVES"):');
+const STORED_MASTER = { recordId: '240939', comboSkuCalc: 'OP-ARTDECO-WAVES' };
+const storedKey = normalizeSku(STORED_MASTER.comboSkuCalc);
+let mastersMatched = new Set();
+let dupWouldMint = 0;
+for (const [label, form] of Object.entries(FORMS)) {
+  const p = parseCombo(form);
+  const matches = p && p.key === storedKey;
+  if (matches) mastersMatched.add(STORED_MASTER.recordId);
+  else dupWouldMint++;
+  console.log(`    ${label.padEnd(8)} key="${p.key}" vs stored="${storedKey}" -> ${matches ? 'MATCH master ' + STORED_MASTER.recordId : 'NO MATCH -> would mint duplicate'}`);
+}
+assert(mastersMatched.size === 1, 'all three forms match exactly ONE master (record 240939)');
+assert(dupWouldMint === 0, '0 duplicate masters would be minted');
+
+// ---- 5) BONUS: live read-only FileMaker resolve (skipped gracefully if no creds) ----
+console.log('\n[5] BONUS live read-only resolve (skipped if FileMaker creds absent):');
+try {
+  const { resolveWallpaperSource } = await import(MOD.href);
+  const results = {};
+  let fmReachable = true;
+  for (const [label, form] of Object.entries(FORMS)) {
+    let r;
+    try { r = await resolveWallpaperSource(form); }
+    catch (e) { fmReachable = false; console.log(`    ${label}: FM unreachable (${e.message})`); break; }
+    results[label] = r;
+    const exId = r._existing ? r._existing.id : null;
+    console.log(`    ${label.padEnd(8)} ok=${r.ok} existingMasterId=${exId ?? 'null'} vid="${r.vid || ''}" reason="${r.reason || ''}"`);
+  }
+  if (fmReachable && Object.keys(results).length === 3) {
+    const ids = Object.values(results).map((r) => (r._existing ? r._existing.id : null)).filter(Boolean);
+    const uid = [...new Set(ids)];
+    console.log(`    matched master ids = ${JSON.stringify(ids)} ; distinct = ${uid.length}`);
+    assert(uid.length <= 1, 'live: the three forms resolve to at most ONE master (0 duplicates)');
+  } else {
+    console.log('    (live read-only FileMaker not available in this env — core string assertions above already prove the fix)');
+  }
+} catch (e) {
+  console.log(`    (bonus live check unavailable: ${e.message} — core string assertions above already prove the fix)`);
+}
+
+console.log(`\n=== RESULT: ${failures === 0 ? 'ALL ASSERTIONS PASSED' : failures + ' ASSERTION(S) FAILED'} ===`);
+process.exit(failures === 0 ? 0 : 1);

← e7b1887 ads-dashboard revenue: FMPro 30d generator + Mac2->Kamatera  ·  back to Filemaker Mcp  ·  TK-10083: fail-closed combo-SKU parser — stop duplicate WALL 64cf597 →