← 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
M lib/invoice.jsM lib/notify.jsA scripts/drain-flagged-skus.mjs
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 →