[object Object]

← back to Sample Followup Sweep

Sample Follow-Up Sweep — phases 1-4 engine + canary vendor (O&L)

a51c7d21960c3bd79bd0c0795e90b09dd94db2fa · 2026-08-14 12:55:15 -0700 · Steve Abrams

Files touched

Diff

commit a51c7d21960c3bd79bd0c0795e90b09dd94db2fa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 14 12:55:15 2026 -0700

    Sample Follow-Up Sweep — phases 1-4 engine + canary vendor (O&L)
---
 .gitignore                       |  9 ++++
 README.md                        | 41 ++++++++++++++++++
 bin/run.js                       | 91 ++++++++++++++++++++++++++++++++++++++++
 data/vendors/osborne-little.json | 34 +++++++++++++++
 lib/compose.js                   | 45 ++++++++++++++++++++
 lib/sweep.js                     | 47 +++++++++++++++++++++
 6 files changed, 267 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6f58160
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+out/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5720ae5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# Sample Follow-Up Sweep
+
+DW ↔ vendor sample-chase automation. For every vendor with memo samples ordered
+>10 days ago and not yet received, compose **one** follow-up letter → draft it to
+`info@` → human batch-sends → stamp `2nd Request = today` on the chased rows.
+
+Canary vendor: **Designers Guild @ Osborne And Little** (O&L USA, acct 1433803).
+
+## Pipeline
+
+| Phase | What | Status here |
+|-------|------|-------------|
+| 1 Sweep | filter a vendor's requested rows: `today − Entered > 10d`, not disco, not received | ✅ `lib/sweep.js` (runs off a seed; swap for live `fm_find` once `API_SampleMemos` exists) |
+| 2 Resolve | recipient = vendor's **Email address for Samples** (skip + report if missing) | ✅ `bin/run.js` |
+| 3 Compose | verbatim letter template, one line per overdue Mfr# | ✅ `lib/compose.js` |
+| 4 Draft | emit `gmail_create_draft` payload + HTML preview (never auto-sends) | ✅ `out/*.draft.json` |
+| 5 Send | **human gate** — you batch-send from info@ Drafts | manual |
+| 6 Stamp | `2nd Request = today` via `fm_update_record` dry-run→commit | needs `API_SampleMemos` layout (else push the FileMaker button) |
+
+## The one FileMaker dependency (for hands-free phases 1 & 6)
+
+Create a Data-API-enabled list layout on **`WALLPAPER2`** named `API_SampleMemos`
+carrying: `Mfr Pattern`, `combo sku`(DW#), `vid`, `account`, `Requested/Again`,
+`Entered`, `Date WP Sample Sent`, `Date Discontinued`, `2nd Request`,
+`2nd Request Notes`. The vendor dashboard (`Main Menu Copy9`) is **not** Data-API
+accessible and the connector cannot run FileMaker scripts, so this thin layout is
+the read/write bridge.
+
+## Run
+
+```sh
+node bin/run.js osborne-little
+open out/osborne-little.preview.html
+```
+
+## Decisions (config per vendor in the seed's `sweep_config`)
+
+- **A** rows already carrying a 2nd Request → `secondRequestPolicy`: `escalate` (fleet default) | `include` (canary matches the letter Steve sent)
+- **B** disco rows → `excludeDisco: true`
+- **C** send mode → draft only (v1); no auto-send
+- **D** cadence → daily, idempotent (a row stamped today is skipped)
diff --git a/bin/run.js b/bin/run.js
new file mode 100644
index 0000000..2a836c6
--- /dev/null
+++ b/bin/run.js
@@ -0,0 +1,91 @@
+'use strict';
+// Phases 1–4 orchestrator for one vendor.
+//   node bin/run.js <vendor-slug>
+// Emits out/<slug>.draft.json (payload for gmail_create_draft) + out/<slug>.preview.html
+
+const fs = require('fs');
+const path = require('path');
+const { sweep } = require('../lib/sweep');
+const { compose } = require('../lib/compose');
+
+const slug = process.argv[2] || 'osborne-little';
+const root = path.join(__dirname, '..');
+const vendor = JSON.parse(fs.readFileSync(path.join(root, 'data', 'vendors', `${slug}.json`), 'utf8'));
+const today = new Date();
+
+// Phase 1 — sweep
+const { followUp, escalation, skipped } = sweep(vendor.rows, vendor.sweep_config || {}, today);
+
+// Phase 2 — resolve recipient
+if (!vendor.sample_email) {
+  console.error(`SKIP ${vendor.name}: no "Email address for Samples" on file — not sending to a guessed address.`);
+  process.exit(1);
+}
+
+// Phase 3 — compose
+const draft = compose(vendor, followUp);
+
+// Phase 4 — emit draft payload + preview (this JSON is exactly what gmail_create_draft consumes)
+const outDir = path.join(root, 'out');
+fs.mkdirSync(outDir, { recursive: true });
+const payload = { account: 'info', to: draft.to, subject: draft.subject, body: draft.html };
+fs.writeFileSync(path.join(outDir, `${slug}.draft.json`), JSON.stringify(payload, null, 2));
+fs.writeFileSync(path.join(outDir, `${slug}.preview.html`), preview(vendor, draft, followUp, escalation, skipped));
+
+// Console summary
+const line = (r) => `  • ${r.mfr}  [${r.dw || '-'}]  ${r.age != null ? r.age + 'd' : 'manual add'}`;
+console.log(`\n=== Sample Follow-Up Sweep — ${vendor.name} ===`);
+console.log(`Recipient (sample email): ${draft.to}`);
+console.log(`Subject: ${draft.subject}`);
+console.log(`\nFollow-up rows (${followUp.length}):`);
+followUp.forEach(r => console.log(line(r)));
+if (escalation.length) {
+  console.log(`\nEscalation — already 2nd-requested (${escalation.length}):`);
+  escalation.forEach(r => console.log(`  ⚠ ${r.mfr}  [${r.dw}]  existing 2nd req ${r.second_request}`));
+}
+if (skipped.length) {
+  console.log(`\nSkipped (${skipped.length}):`);
+  skipped.forEach(s => console.log(`  - ${s.row.mfr}: ${s.reason}`));
+}
+console.log(`\nArtifacts:`);
+console.log(`  out/${slug}.draft.json    → payload for gmail_create_draft (account=info)`);
+console.log(`  out/${slug}.preview.html  → open in browser`);
+console.log(`\nPhase 5 (send) + Phase 6 (stamp 2nd Request = today) stay gated / manual.\n`);
+
+function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
+
+function preview(v, d, fu, escl, sk) {
+  const rowsPanel = fu.map(r => `<li>${esc(r.mfr)} <span class="dim">[${esc(r.dw || '-')}] ${r.age != null ? r.age + 'd' : 'manual'}</span></li>`).join('');
+  const esclPanel = escl.length ? `<h4>Escalation — already 2nd-requested</h4><ul>${escl.map(r => `<li>${esc(r.mfr)} <span class="dim">2nd req ${esc(r.second_request)}</span></li>`).join('')}</ul>` : '';
+  const skPanel = sk.length ? `<h4>Skipped</h4><ul>${sk.map(s => `<li>${esc(s.row.mfr)} <span class="dim">${esc(s.reason)}</span></li>`).join('')}</ul>` : '';
+  return `<!doctype html><html><head><meta charset="utf-8"><title>Sweep — ${esc(v.name)}</title>
+<style>
+ body{background:#f2f2f2;margin:0;padding:24px;font-family:Arial,Helvetica,sans-serif;color:#222}
+ .wrap{max-width:1040px;margin:0 auto;display:grid;grid-template-columns:1fr 320px;gap:18px}
+ .card{background:#fff;border:1px solid #ddd;border-radius:8px;padding:24px 28px;box-shadow:0 1px 4px rgba(0,0,0,.06)}
+ .card p{font-size:14px;line-height:1.5;margin:0 0 12px}
+ .env{font-size:13px;color:#555;margin-bottom:14px}
+ .env b{color:#222}
+ hr{border:none;border-top:1px solid #ccc;margin:14px 0}
+ .side{background:#fff;border:1px solid #ddd;border-radius:8px;padding:16px 18px;font-size:13px;height:fit-content}
+ .side h3{margin:0 0 4px} .side h4{margin:14px 0 4px;color:#444}
+ .side ul{margin:0;padding-left:18px} .side li{margin:2px 0;line-height:1.35}
+ .dim{color:#999;font-size:12px}
+ .status{color:#b8860b;font-weight:bold;margin-bottom:10px}
+</style></head><body><div class="wrap">
+ <div class="card">
+  <div class="status">DRAFT — staged in info@ → Drafts (not sent). This is exactly what sends.</div>
+  <div class="env"><div><b>From:</b> Designer Wallcoverings &lt;info@designerwallcoverings.com&gt;</div>
+   <div><b>To:</b> ${esc(d.to)}</div><div><b>Subject:</b> ${esc(d.subject)}</div></div>
+  <hr>${d.html}
+ </div>
+ <div class="side">
+  <h3>Sweep result</h3>
+  <div class="dim">${esc(v.name)} · acct ${esc(v.account_number)}</div>
+  <h4>Follow-up (${fu.length})</h4><ul>${rowsPanel}</ul>
+  ${esclPanel}${skPanel}
+  <h4>Config</h4>
+  <div class="dim">&gt;${(v.sweep_config||{}).minAgeDays||10}d · disco-excluded · 2nd-req: ${(v.sweep_config||{}).secondRequestPolicy||'escalate'}</div>
+ </div>
+</div></body></html>`;
+}
diff --git a/data/vendors/osborne-little.json b/data/vendors/osborne-little.json
new file mode 100644
index 0000000..707160f
--- /dev/null
+++ b/data/vendors/osborne-little.json
@@ -0,0 +1,34 @@
+{
+  "name": "Designers Guild @ Osborne And Little",
+  "fmpro_acct": "141363",
+  "account_number": "1433803",
+  "sample_email": "Polancol@oalusa.com",
+  "ship_to": {
+    "name": "Designer Wallcoverings",
+    "line1": "15442 Ventura Blvd. #102",
+    "city_state_zip": "Sherman Oaks, CA 91403",
+    "phone": "1-888-373-4564"
+  },
+  "sweep_config": {
+    "minAgeDays": 10,
+    "excludeDisco": true,
+    "requireNotReceived": true,
+    "secondRequestPolicy": "include"
+  },
+  "rows": [
+    { "mfr": "W7780-13-Regency Flock Velvet Stripe-Red", "dw": "EUR71111", "client": "Charlotte", "entered": "2026-07-22" },
+    { "mfr": "Belles Rives WALLPAPER", "dw": "DWBW600417", "client": "Charlotte", "entered": "2026-07-22" },
+    { "mfr": "PCL7033-02", "dw": "EUR80616", "client": "The Hunter", "entered": "2026-07-20" },
+    { "mfr": "FRL5217/01", "dw": "RALPH1007", "client": "Kathiann", "entered": "2026-07-21" },
+    { "mfr": "PCL7033-02", "dw": "EUR80616", "client": "The Hunter", "entered": "2026-07-17" },
+    { "mfr": "PDG646/07", "dw": "EUR70041", "client": "Margie", "entered": "2026-07-17" },
+    { "mfr": "P555-17-Ajanta Plaster", "dw": "EUR90140", "client": "", "entered": "2026-07-15" },
+    { "mfr": "P555-16-Ajanta Plaster", "dw": "EUR90139", "client": "", "entered": "2026-07-15" },
+    { "mfr": "PJD6009/01", "dw": "DWDG987895", "client": "Alia Garcia", "entered": "2026-07-13" },
+    { "mfr": "FRL5229/01", "dw": "RALPH1077", "client": "Jay Rinehart", "entered": "2026-07-09", "gap_before": true },
+    { "mfr": "PCL7033 Prête-Moi Ta Plume! Feather Panel 02", "dw": "EUR80616", "client": "Peyton", "entered": "2026-07-02" },
+    { "mfr": "LCW1035.004.0", "dw": "DWKK123171", "client": "Emily Guth", "entered": "2026-06-22", "second_request": "2026-08-03" },
+    { "mfr": "Butterfly Parade 05 Wallpaper | Christian Lacroix Europe", "dw": "", "client": "", "manual_add": true },
+    { "mfr": "Butterfly Parade 01 Multi Wallpaper | Christian Lacroix Europe", "dw": "", "client": "", "manual_add": true }
+  ]
+}
diff --git a/lib/compose.js b/lib/compose.js
new file mode 100644
index 0000000..4bf5973
--- /dev/null
+++ b/lib/compose.js
@@ -0,0 +1,45 @@
+'use strict';
+// Phase 3 — Compose. vendor + follow-up rows -> { to, subject, html }
+// Verbatim to the letter Steve approved in the canary.
+
+function esc(s) {
+  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+}
+
+function compose(vendor, rows) {
+  const account = vendor.account_number;
+  const lines = rows
+    .map(r => (r.gap_before ? '<br>\n' : '') + esc(r.mfr))
+    .join('<br>\n');
+
+  const subject = `Sample Follow-Up — Outstanding Memos (Acct ${account}) — Designer Wallcoverings`;
+  const s = vendor.ship_to;
+
+  const html = `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#222;line-height:1.5">
+<p>We are following up because we have not received the following samples from 10 days ago.<br>
+Hopefully we removed any disco items internally from this list before asking again.</p>
+<hr style="border:none;border-top:1px solid #ccc">
+<p><strong>Our account number is ${esc(account)}</strong></p>
+<p>We ordered 1 memo sample of each item below:</p>
+<p><strong>Manufacturer Number</strong></p>
+<hr style="border:none;border-top:1px solid #ccc">
+<p style="line-height:1.7">
+${lines}
+</p>
+<hr style="border:none;border-top:1px solid #ccc">
+<p>Ship to the address below.</p>
+<p><strong>Sidemark: Samples ASAP</strong></p>
+<p>
+${esc(s.name)}<br>
+${esc(s.line1)}<br>
+${esc(s.city_state_zip)}<br>
+${esc(s.phone)}
+</p>
+<p>We truly appreciate the help.</p>
+<p>Best Regards,<br>Showroom Manager</p>
+</div>`;
+
+  return { to: vendor.sample_email, subject, html };
+}
+
+module.exports = { compose };
diff --git a/lib/sweep.js b/lib/sweep.js
new file mode 100644
index 0000000..75f93e3
--- /dev/null
+++ b/lib/sweep.js
@@ -0,0 +1,47 @@
+'use strict';
+// Phase 1 — Sweep.
+// Given a vendor's requested-sample rows, split them into the follow-up set,
+// an escalation set (already 2nd-requested), and skipped (with reasons).
+// Mirrors the FileMaker "WALLPAPER2 Requested" portal + the >10-day rule.
+
+function ageDays(entered, today) {
+  if (!entered) return null;
+  const ms = today.getTime() - new Date(entered + 'T00:00:00').getTime();
+  return Math.floor(ms / 86400000);
+}
+
+function sweep(rows, opts = {}, today = new Date()) {
+  const {
+    minAgeDays = 10,
+    excludeDisco = true,
+    requireNotReceived = true,
+    secondRequestPolicy = 'escalate', // 'escalate' (default A) | 'include'
+  } = opts;
+
+  const followUp = [];
+  const escalation = [];
+  const skipped = [];
+
+  for (const r of rows) {
+    const age = ageDays(r.entered, today);
+    const overdue = r.manual_add === true || (age !== null && age > minAgeDays);
+
+    if (excludeDisco && r.disco) { skipped.push({ row: r, reason: 'discontinued' }); continue; }
+    if (requireNotReceived && r.received) { skipped.push({ row: r, reason: 'already received' }); continue; }
+    if (!overdue) {
+      skipped.push({ row: r, reason: age === null ? 'no entered date' : `only ${age}d old (<=${minAgeDays})` });
+      continue;
+    }
+
+    if (r.second_request) {
+      if (secondRequestPolicy === 'include') followUp.push({ ...r, age });
+      else escalation.push({ ...r, age });
+      continue;
+    }
+    followUp.push({ ...r, age });
+  }
+
+  return { followUp, escalation, skipped };
+}
+
+module.exports = { sweep, ageDays };

(oldest)  ·  back to Sample Followup Sweep  ·  Add 60-day dead-lead ceiling + next vendor (Scalamandre) fro d8d9ad8 →