[object Object]

← back to Filemaker Mcp

invoice: flag+notify (not swallow) SKUs with no findable mfr number

f8ad188e57675b448f9d4538bbc6e54b9fd1d2b2 · 2026-07-10 11:37:54 -0700 · Steve Abrams

createInvoiceForOrder still creates the invoice line when ensureWallpaper
returns {ok:false} FLAGGED or throws (money must never be blocked by a catalog
data gap), but the outer catch{} no longer silently swallows it. New else/catch
branch calls lib/notify.js flagSku():
  1. console.warn a loud single line (order/invoice/sku/reason).
  2. idempotent append to data/flagged-skus.jsonl (key=orderId:invoice:sku;
     stores ts,orderId,orderName,custPO,invoice,sku,dwSku,reason,resolved:false).
  3. internal Slack (#general) + George email to steve-office — INTERNAL ops
     signal only, never customer-facing.
Graft from DTD verdict C: scripts/drain-flagged-skus.mjs reads the queue, groups
unresolved rows by Cust PO, re-runs ensureWallpaper, writes VID+mfr onto matching
invoice lines (same copy-lookup write as ensure-wallpaper-for-order.mjs), and
marks rows resolved:true. --dry-run supported.

Fixes the silent-swallow that let 102660/102662 (Cust PO 32580) ship blank
vid/mfr unnoticed. Money/tax/shipping logic untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit f8ad188e57675b448f9d4538bbc6e54b9fd1d2b2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 10 11:37:54 2026 -0700

    invoice: flag+notify (not swallow) SKUs with no findable mfr number
    
    createInvoiceForOrder still creates the invoice line when ensureWallpaper
    returns {ok:false} FLAGGED or throws (money must never be blocked by a catalog
    data gap), but the outer catch{} no longer silently swallows it. New else/catch
    branch calls lib/notify.js flagSku():
      1. console.warn a loud single line (order/invoice/sku/reason).
      2. idempotent append to data/flagged-skus.jsonl (key=orderId:invoice:sku;
         stores ts,orderId,orderName,custPO,invoice,sku,dwSku,reason,resolved:false).
      3. internal Slack (#general) + George email to steve-office — INTERNAL ops
         signal only, never customer-facing.
    Graft from DTD verdict C: scripts/drain-flagged-skus.mjs reads the queue, groups
    unresolved rows by Cust PO, re-runs ensureWallpaper, writes VID+mfr onto matching
    invoice lines (same copy-lookup write as ensure-wallpaper-for-order.mjs), and
    marks rows resolved:true. --dry-run supported.
    
    Fixes the silent-swallow that let 102660/102662 (Cust PO 32580) ship blank
    vid/mfr unnoticed. Money/tax/shipping logic untouched.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/invoice.js                 | 16 ++++++++-
 lib/notify.js                  | 75 ++++++++++++++++++++++++++++++++++++++++++
 scripts/drain-flagged-skus.mjs | 71 +++++++++++++++++++++++++++++++++++++++
 3 files changed, 161 insertions(+), 1 deletion(-)

diff --git a/lib/invoice.js b/lib/invoice.js
index fd65875..0127910 100644
--- a/lib/invoice.js
+++ b/lib/invoice.js
@@ -13,6 +13,7 @@
 //  - Notes stamped "Imported by Otto — <date time>"
 import * as fm from '../src/fm-client.js';
 import { ensureWallpaper } from './wallpaper.js';
+import { flagSku } from './notify.js';
 
 const HDR = 'Invoice to Client Copy';
 const SHIP_LAYOUT = 'Order Detail - Main - Shipping'; // exposes VID + Detail 1 Mfr Number
@@ -203,12 +204,25 @@ export async function createInvoiceForOrder(order, accountNumber, { commit = tru
     // dw_unified so the invoice's width/name/vid/supplier lookups resolve, then
     // write the copy-lookup fields (VID + Detail 1 Mfr Number) onto this line.
     // Never let this break invoicing — the WALLPAPER completion is best-effort.
+    // If the SKU can't be resolved (no findable mfr number) OR ensureWallpaper
+    // throws, the invoice line STILL stands (money must never be blocked by a
+    // catalog gap) but we LOUDLY flag it instead of silently writing blank
+    // vid/mfr — console.warn + idempotent data/flagged-skus.jsonl queue + an
+    // internal Slack + George alert to Steve (drainable via drain-flagged-skus).
     try {
       const wp = await ensureWallpaper(dwSku(li.sku));
       if (wp?.ok) {
         try { await fm.updateRecord('invoice', SHIP_LAYOUT, id, { 'VID': wp.vid || '', 'Detail 1 Mfr Number': wp.mfr }, { dryRun: false }); } catch {}
+      } else {
+        await flagSku({ orderId: order.id, orderName: order.name || `#${order.order_number}`,
+          invoiceNumber: createdInv, sku: li.sku, dwSku: dwSku(li.sku),
+          reason: wp?.reason || 'ensureWallpaper returned not-ok' }).catch(() => {});
       }
-    } catch {}
+    } catch (e) {
+      await flagSku({ orderId: order.id, orderName: order.name || `#${order.order_number}`,
+        invoiceNumber: createdInv, sku: li.sku, dwSku: dwSku(li.sku),
+        reason: `ensureWallpaper threw: ${e.message}` }).catch(() => {});
+    }
 
     // paid-in-full = the EXACT order total (merch + tax + shipping) on the first
     // record, 0 on the SKU records. Computed directly (not read from GRAND TOTAL)
diff --git a/lib/notify.js b/lib/notify.js
index 83f29dd..26f12ee 100644
--- a/lib/notify.js
+++ b/lib/notify.js
@@ -1,3 +1,78 @@
+import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const NOTIFY_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const FLAGGED_QUEUE = join(NOTIFY_ROOT, 'data', 'flagged-skus.jsonl');
+const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const GEORGE_AUTH = 'Basic ' + Buffer.from((process.env.GEORGE_BASIC || 'admin:')).toString('base64');
+
+// Idempotency key for a flagged SKU: one row per (order, invoice, sku).
+const flagKey = (orderId, invoiceNumber, sku) => `${orderId}:${invoiceNumber}:${sku}`;
+
+// Read the queue's existing idempotency keys so re-runs don't double-append.
+function existingFlagKeys() {
+  try {
+    return new Set(readFileSync(FLAGGED_QUEUE, 'utf8').split('\n').filter(Boolean)
+      .map((l) => { try { return JSON.parse(l).key; } catch { return null; } }).filter(Boolean));
+  } catch { return new Set(); }
+}
+
+// Surface a SKU that ensureWallpaper() could NOT resolve (no findable mfr number)
+// LOUDLY instead of swallowing it. The invoice line is STILL created by the caller;
+// this only flags. Three surfaces: (1) console.warn, (2) idempotent JSONL queue at
+// data/flagged-skus.jsonl (drainable by scripts/drain-flagged-skus.mjs), (3) internal
+// Slack + George email to Steve. INTERNAL ops signal only — never customer-facing.
+export async function flagSku({ orderId, orderName, invoiceNumber, sku, dwSku, reason }) {
+  const key = flagKey(orderId, invoiceNumber, sku);
+  const custPO = String(orderName || '').replace(/^#/, '');
+
+  // (1) loud console line
+  console.warn(`⚠️ FLAGGED SKU — order ${orderName || orderId} · invoice ${invoiceNumber} · sku ${sku} (dwSku ${dwSku}) · ${reason} · blank vid/mfr written; run: node scripts/ensure-wallpaper-for-order.mjs ${custPO}`);
+
+  // (2) idempotent JSONL append
+  let queued = false;
+  try {
+    const dir = dirname(FLAGGED_QUEUE);
+    if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
+    if (!existingFlagKeys().has(key)) {
+      appendFileSync(FLAGGED_QUEUE, JSON.stringify({
+        key, ts: new Date().toISOString(),
+        orderId, orderName, custPO, invoiceNumber, sku, dwSku, reason, resolved: false,
+      }) + '\n');
+      queued = true;
+    }
+  } catch (e) { console.warn('flagSku: queue append failed:', e.message); }
+
+  // (3) internal notify — Slack + George email (both best-effort, never throw)
+  const msg = `SKU has no findable mfr number — order ${orderName || orderId}, invoice #${invoiceNumber}, SKU ${sku} (${reason}). Invoice line created with blank VID + Detail 1 Mfr Number. Create/complete its FileMaker WALLPAPER master, then run: node scripts/ensure-wallpaper-for-order.mjs ${custPO}  (or: node scripts/drain-flagged-skus.mjs)`;
+  await postFlagToSlack(msg).catch(() => {});
+  await emailFlagToSteve({ invoiceNumber, sku, custPO, reason, msg }).catch(() => {});
+  return { key, queued };
+}
+
+async function postFlagToSlack(text) {
+  const token = process.env.SLACK_BOT_TOKEN;
+  const channel = process.env.SLACK_GENERAL_CHANNEL;
+  if (!token || !channel) return { ok: false, error: 'slack not configured' };
+  const res = await fetch('https://slack.com/api/chat.postMessage', {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json; charset=utf-8' },
+    body: JSON.stringify({ channel, text: `🚩 *Invoice flag* — ${text}`, unfurl_links: false }),
+  });
+  return res.json();
+}
+
+async function emailFlagToSteve({ invoiceNumber, sku, custPO, reason, msg }) {
+  const res = await fetch(`${GEORGE}/api/send`, {
+    method: 'POST', headers: { Authorization: GEORGE_AUTH, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ account: 'steve-office', to: 'steve@designerwallcoverings.com',
+      subject: `FileMaker invoice flag: SKU ${sku} on invoice ${invoiceNumber} (Cust PO ${custPO}) has no mfr number`,
+      body: `<p>An invoice line was created with a blank VID + Detail 1 Mfr Number because its SKU has no findable manufacturer number.</p><p><b>${msg}</b></p><p>Reason: ${reason}</p>` }),
+  });
+  return res.ok;
+}
+
 // Post a new-order alert to Slack #general: client name, invoice#, time, total, order link.
 export async function postOrderToSlack({ order, invoiceNumber, total, clientName }) {
   const token = process.env.SLACK_BOT_TOKEN;
diff --git a/scripts/drain-flagged-skus.mjs b/scripts/drain-flagged-skus.mjs
new file mode 100644
index 0000000..d496720
--- /dev/null
+++ b/scripts/drain-flagged-skus.mjs
@@ -0,0 +1,71 @@
+// Drain the flagged-SKU queue (data/flagged-skus.jsonl) written by lib/notify.js
+// flagSku() whenever an invoice line was created with a blank VID + Detail 1 Mfr
+// Number because ensureWallpaper() couldn't find a mfr number for the SKU.
+//
+// For each UNRESOLVED row it re-runs ensureWallpaper (the WALLPAPER master may now
+// exist / dw_unified may now carry the mfr), and — reusing the exact copy-lookup
+// write from scripts/ensure-wallpaper-for-order.mjs — writes VID + Detail 1 Mfr
+// Number onto every matching invoice line for that SKU's Cust PO. Rows that now
+// resolve are marked resolved:true; rows that still can't resolve stay open.
+//
+//   node scripts/drain-flagged-skus.mjs            # drain + rewrite the queue
+//   node scripts/drain-flagged-skus.mjs --dry-run  # report only, no writes
+import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+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, '');
+}
+const DRY = process.argv.includes('--dry-run');
+const QUEUE = join(ROOT, 'data', 'flagged-skus.jsonl');
+const ISHIP = 'Order Detail - Main - Shipping';
+
+if (!existsSync(QUEUE)) { console.log('no flagged-skus.jsonl — nothing to drain.'); process.exit(0); }
+
+const fm = await import('../src/fm-client.js');
+const { ensureWallpaper } = await import('../lib/wallpaper.js');
+
+const rows = readFileSync(QUEUE, 'utf8').split('\n').filter(Boolean)
+  .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+const open = rows.filter((r) => !r.resolved);
+console.log(`queue: ${rows.length} row(s), ${open.length} unresolved`);
+if (!open.length) process.exit(0);
+
+// group unresolved by Cust PO so we pull each invoice's lines once
+const byPO = new Map();
+for (const r of open) {
+  const po = r.custPO || String(r.orderName || '').replace(/^#/, '');
+  if (!byPO.has(po)) byPO.set(po, []);
+  byPO.get(po).push(r);
+}
+
+const resolvedKeys = new Set();
+for (const [po, group] of byPO) {
+  if (!po) { console.log('skip row with no Cust PO'); continue; }
+  const { records } = await fm.findRecords('invoice', ISHIP, { 'Cust PO': '==' + po }, { limit: 30, sort: [{ fieldName: 'Invoice', sortOrder: 'ascend' }] }).catch(() => ({ records: [] }));
+  const skuOf = (rf) => (rf['WC Pattern Number AKA DW SKU(1)'] || rf['WC Pattern Number AKA DW SKU'] || '').trim();
+  const combos = [...new Set(group.map((r) => r.dwSku).filter(Boolean))];
+  console.log(`Cust PO ${po}: ${combos.length} flagged SKU(s): ${combos.join(', ')}`);
+  for (const combo of combos) {
+    const wp = await ensureWallpaper(combo);
+    if (!wp.ok) { console.log(`  ${combo}: STILL FLAGGED — ${wp.reason}`); continue; }
+    if (!DRY) {
+      for (const rec of records) {
+        if (skuOf(rec.fieldData) !== combo) continue;
+        try { await fm.updateRecord('invoice', ISHIP, rec.recordId, { 'VID': wp.vid, 'Detail 1 Mfr Number': wp.mfr }, { dryRun: false }); }
+        catch (e) { console.log(`  ${combo} invoice line err ${e.fmCode}`); }
+      }
+    }
+    console.log(`  ${combo}: ${DRY ? 'WOULD RESOLVE' : 'RESOLVED'}  mfr=${wp.mfr}  vid=${wp.vid}  [${wp.src}]`);
+    for (const r of group) if (r.dwSku === combo) resolvedKeys.add(r.key);
+  }
+}
+
+if (DRY) { console.log(`\n[dry-run] would resolve ${resolvedKeys.size} row(s). queue unchanged.`); process.exit(0); }
+
+// rewrite the queue with resolved rows flagged (keep them for audit trail)
+const out = rows.map((r) => resolvedKeys.has(r.key) ? { ...r, resolved: true, resolvedAt: new Date().toISOString() } : r);
+writeFileSync(QUEUE, out.map((r) => JSON.stringify(r)).join('\n') + '\n');
+console.log(`\nresolved ${resolvedKeys.size} row(s); ${out.filter((r) => !r.resolved).length} still open.`);

← 4d2ed04 wallpaper: resolve mfr# from flat metafields + vendor_catalo  ·  back to Filemaker Mcp  ·  invoice: backfill vid+mfr onto 158 pre-hook blank-mfr Otto i 478c8b2 →