[object Object]

← back to Sample Followup Sweep

sample-followup: FIX 2nd-request stamping — was silently stamping nothing

5ab8a3d9c590fa17f0ac093fd8d53611ea91ded7 · 2026-09-01 12:37:23 -0700 · Steve

The Sent-poller matched the subject account # against FileMaker's account field,
but that field is a DW-internal sequence # (1062xxx), not the vendor account — so
6/25 processed sends stamped ZERO records while the ledger filled up (looked healthy,
did nothing). Rewrote matching: recipient email -> candidate vids (all slug variants,
not last-wins) -> stamp ONLY records whose Manufacturer # actually appears in the SENT
letter body. Body-presence is precise + immune to shared-desk aliasing (Christian
Lacroix via Osborne desk, Anna French via Thibaut desk stamp only if truly in the
letter). Also switched the search phrase to the no-hyphen 'Outstanding Memos' (less
flaky than 'Sample Follow-Up'). Stamps Date Sample Request Letter Sent +
Date Email Sent to Vendor after 10 Days (no dedicated 2nd-Request field exists via API).

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

Files touched

Diff

commit 5ab8a3d9c590fa17f0ac093fd8d53611ea91ded7
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Sep 1 12:37:23 2026 -0700

    sample-followup: FIX 2nd-request stamping — was silently stamping nothing
    
    The Sent-poller matched the subject account # against FileMaker's account field,
    but that field is a DW-internal sequence # (1062xxx), not the vendor account — so
    6/25 processed sends stamped ZERO records while the ledger filled up (looked healthy,
    did nothing). Rewrote matching: recipient email -> candidate vids (all slug variants,
    not last-wins) -> stamp ONLY records whose Manufacturer # actually appears in the SENT
    letter body. Body-presence is precise + immune to shared-desk aliasing (Christian
    Lacroix via Osborne desk, Anna French via Thibaut desk stamp only if truly in the
    letter). Also switched the search phrase to the no-hyphen 'Outstanding Memos' (less
    flaky than 'Sample Follow-Up'). Stamps Date Sample Request Letter Sent +
    Date Email Sent to Vendor after 10 Days (no dedicated 2nd-Request field exists via API).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/watch-sent-stamp.mjs | 92 +++++++++++++++++++++++++++++++-------------
 1 file changed, 65 insertions(+), 27 deletions(-)

diff --git a/scripts/watch-sent-stamp.mjs b/scripts/watch-sent-stamp.mjs
index 8e0694e..e3a1e82 100644
--- a/scripts/watch-sent-stamp.mjs
+++ b/scripts/watch-sent-stamp.mjs
@@ -21,7 +21,8 @@ const MAX_AGE_DAYS = 60;   // >60 = dead lead, never chased
 const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
 const fenv = cfg?.mcpServers?.filemaker?.env || {};
 for (const k of ['FM_CLOUD_HOST', 'FM_CLARIS_EMAIL', 'FM_CLARIS_PASSWORD']) process.env[k] = fenv[k];
-process.env.FM_READONLY = '0';
+const DRY_RUN = process.argv.includes('--dry') || process.env.DRY_RUN === '1';
+process.env.FM_READONLY = DRY_RUN ? '1' : '0';
 const fm = await import('/Users/macstudio3/Projects/filemaker-mcp/src/fm-client.js');
 
 const readJSON = (f, d) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return d; } };
@@ -41,21 +42,58 @@ const genv = k => { for (const f of ['/Users/macstudio3/Projects/Designer-Wallco
 let auth = genv('GEORGE_BASIC_AUTH'); if (!auth.includes(':')) auth = 'admin:' + genv('GEORGE_BASIC_AUTH_PASS');
 auth = 'Basic ' + Buffer.from(auth).toString('base64');
 function gsearch(q) { return new Promise(res => { const p = '/api/messages?account=info&maxResults=30&q=' + encodeURIComponent(q); const rq = http.request({ host: '127.0.0.1', port: 9850, path: p, method: 'GET', headers: { Authorization: auth } }, r => { let d = ''; r.on('data', x => d += x); r.on('end', () => { try { const j = JSON.parse(d); res(j.messages || (Array.isArray(j) ? j : [])); } catch { res([]); } }); }); rq.on('error', () => res([])); rq.end(); }); }
+function gget(id) { return new Promise(res => { const rq = http.request({ host: '127.0.0.1', port: 9850, path: `/api/messages/${id}?account=info`, method: 'GET', headers: { Authorization: auth } }, r => { let d = ''; r.on('data', x => d += x); r.on('end', () => { try { res(JSON.parse(d)); } catch { res({}); } }); }); rq.on('error', () => res({})); rq.end(); }); }
 
-// Stamp the outstanding, NOT-YET-STAMPED memos for one account, in the rolling chase window.
-// Idempotent: FIELD_SENT empty ('=') filter means a memo already stamped is never re-stamped
-// (so re-processing / overlapping emails can't move the 2nd-request date forward). LAYOUT carries
-// account + Date WP Sample Sent + FIELD_SENT + FIELD_2ND_DATE, so no cross-layout hop is needed.
-async function stampAccount(acct, win, today) {
-  const rows = (await fm.findRecords(DB, LAYOUT,
-    { account: `==${acct}`, 'Date WP Sample Sent': '=', [FIELD_SENT]: '=', 'today for client': win },
-    { limit: 300 }).catch(() => ({ records: [] }))).records;
-  const stamped = [];
-  for (const r of rows) {
-    // Steve 8/25: on a real send stamp BOTH the dedup guard (Date Email Sent…) AND the
-    // 2nd-request date (Date Sample Request Letter Sent — the chosen API-reachable date field).
-    const res = await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: today, [FIELD_2ND_DATE]: today }, { dryRun: false }).catch(() => ({}));
-    if (res.committed) stamped.push((r.fieldData['Mfr Pattern'] || r.recordId));
+// Steve 9/01 FIX: identify the vendor by the follow-up's RECIPIENT EMAIL → vid, NOT the subject
+// account #. FileMaker's `account` field is a DW-internal sequence #, not the vendor account, so the
+// old account-match stamped nothing (ledger: 6/25 matched 0). Email→vid also covers "On File" vendors.
+// One vendor email can map to MULTIPLE vid variants (Thibaut THI/THIB, Osborne DGD/OSB); the records
+// may sit under any of them, so map email → the SET of candidate vids and stamp across all of them.
+function buildEmailVid() {
+  const fleet = readJSON(join(ROOT, 'data/fleet.json'), { vendors: [] }).vendors;
+  const contacts = readJSON(join(ROOT, 'data/contacts.json'), {});
+  // a slug can map to MULTIPLE fleet vids (thib → THIB, ANNA, ANNA FRENCH); keep ALL, not last-wins.
+  const slugVids = {}; for (const v of fleet) { if (!v.slug) continue; (slugVids[v.slug] = slugVids[v.slug] || new Set()).add(String(v.vid || '').toUpperCase()); }
+  const map = {};
+  for (const [slug, c] of Object.entries(contacts)) {
+    const vids = slugVids[slug]; if (!vids || !c) continue;
+    for (const field of [c.sample_email, c.main_email]) for (const one of String(field || '').toLowerCase().split(/[,;]\s*/)) { const t = one.trim(); if (t) { const set = (map[t] = map[t] || new Set()); for (const v of vids) set.add(v); } }
+  }
+  return map;
+}
+const EMAIL_VID = buildEmailVid();
+function vidsForRecipient(to) {
+  const out = new Set();
+  for (const e of (String(to || '').toLowerCase().match(/[\w.+-]+@[\w.-]+\.\w+/g) || [])) if (EMAIL_VID[e]) for (const v of EMAIL_VID[e]) out.add(v);
+  return [...out];
+}
+
+// Stamp a vendor's outstanding, NOT-YET-STAMPED memos by VID. Set-difference over recordId (stable
+// across layouts — the proven scheduled-run pattern): layout A 'REPORT ON SAMPLES ORDERED' has vid;
+// layout B 'Report for old memo samples' has the stamp fields. Idempotent (already-stamped excluded).
+async function stampVids(vids, bodyText, win, today) {
+  // PRECISE scope: stamp a vendor record ONLY if its Manufacturer # actually appears in the SENT
+  // letter body. This is immune to shared-desk aliasing (Christian Lacroix via Osborne's desk, Anna
+  // French via Thibaut's desk) — a sibling vid's item is stamped only if it was really in the letter.
+  const bodyNorm = String(bodyText || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').toLowerCase();
+  const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
+  const Bs = (await fm.findRecords(DB, LAYOUT,
+    { [FIELD_SENT]: '*', 'Date WP Sample Sent': '=', 'today for client': win }, { limit: 800 }).catch(() => ({ records: [] }))).records;
+  const already = new Set(Bs.map(r => String(r.recordId)));
+  const stamped = [], done = new Set();
+  for (const vid of vids) {
+    const A = (await fm.findRecords(DB, 'REPORT ON SAMPLES ORDERED',
+      { vid: `==${vid}`, 'Date WP Sample Sent': '=', 'today for client': win }, { limit: 300 }).catch(() => ({ records: [] }))).records;
+    for (const r of A) {
+      const id = String(r.recordId); if (already.has(id) || done.has(id)) continue;   // already stamped / dedup
+      const mfr = (r.fieldData['Mfr Pattern'] || '').trim(); if (!mfr) continue;
+      if (!bodyNorm.includes(norm(mfr))) continue;          // only items PRINTED IN the sent letter
+      done.add(id);
+      if (DRY_RUN) { stamped.push(mfr + ' [dry]'); continue; }
+      // Steve 8/25: stamp BOTH the dedup guard (Date Email Sent…) AND the 2nd-request date.
+      const res = await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: today, [FIELD_2ND_DATE]: today }, { dryRun: false }).catch(() => ({}));
+      if (res.committed) stamped.push(mfr);
+    }
   }
   return stamped;
 }
@@ -65,22 +103,22 @@ async function pass() {
   const win = chaseWindow();
   const today = fmt(new Date());   // per-pass so a midnight-crossing loop stamps the real date
   // NB: the em-dash in the full subject breaks Gmail matching over HTTP — match the ASCII prefix.
-  // newer_than:2d so a poll gap never loses a send; the per-message ledger guards against re-stamping.
-  const msgs = await gsearch('in:sent subject:"Sample Follow-Up" newer_than:2d');
-  if (process.env.DEBUG) console.log(`  [debug] gsearch → ${msgs.length} msg(s); win ${win}; ledger has ${Object.keys(seen).length}`);
+  // newer_than:2d so a poll gap never loses a send; the per-message ledger guards against re-work.
+  const msgs = await gsearch('in:sent subject:"Outstanding Memos" newer_than:3d');   // no hyphen → less flaky than "Sample Follow-Up"
+  if (process.env.DEBUG) console.log(`  [debug] gsearch → ${msgs.length} msg(s); win ${win}; ledger has ${Object.keys(seen).length}${DRY_RUN ? ' · DRY-RUN' : ''}`);
   let acted = 0;
   for (const m of msgs) {
-    if (seen[m.id]) continue;
-    const acctM = String(m.subject || '').match(/Acct\s*(\d+)/i);   // subject = "…(Acct NNNNN)"
-    if (!acctM) { seen[m.id] = { skip: 'no acct in subject', subject: m.subject || '', to: m.to || '' }; continue; }
-    const acct = acctM[1];
-    const stamped = await stampAccount(acct, win, today);
-    seen[m.id] = { acct, stamped: stamped.length, patterns: stamped, to: m.to || '', at: new Date().toISOString() };
+    if (seen[m.id] && !DRY_RUN) continue;
+    const vids = vidsForRecipient(m.to);   // recipient email → candidate vendor vids
+    if (!vids.length) { if (!DRY_RUN) seen[m.id] = { skip: 'no vid for recipient', to: m.to || '', subject: m.subject || '' }; console.log(`  · no vid mapping for ${m.to || '(no to)'}`); continue; }
+    const full = await gget(m.id);                                   // fetch the sent letter body
+    const stamped = await stampVids(vids, full.body || '', win, today);
+    if (!DRY_RUN) seen[m.id] = { vids, stamped: stamped.length, patterns: stamped, to: m.to || '', at: new Date().toISOString() };
     acted += stamped.length;
-    console.log(`  ✓ SENT → ${m.to || ''} (Acct ${acct}) → stamped ${stamped.length} memo(s) [${stamped.join(' + ')}] 2ndReq=${today}`);
+    console.log(`  ${DRY_RUN ? '·' : '✓'} SENT → ${m.to || ''} [${vids.join('/')}] → ${DRY_RUN ? 'WOULD stamp' : 'stamped'} ${stamped.length} memo(s) [${stamped.join(' + ')}] 2ndReq=${today}`);
   }
-  writeFileSync(LEDGER, JSON.stringify(seen, null, 2));
-  if (!acted) console.log(`  (no new sent follow-ups to stamp) — ${new Date().toLocaleTimeString()}`);
+  if (!DRY_RUN) writeFileSync(LEDGER, JSON.stringify(seen, null, 2));
+  if (!acted) console.log(`  (no ${DRY_RUN ? 'stampable' : 'new sent'} follow-ups) — ${new Date().toLocaleTimeString()}`);
   return acted;
 }
 

← ea9f7d9 auto-data-snapshot: 2026-09-01T12:16:45 (5 data files) — dat  ·  back to Sample Followup Sweep  ·  sample-followup: env knobs (SEARCH_DAYS/MAX_AGE) to backfill 35ba693 →