← 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
A .gitignoreA README.mdA bin/run.jsA data/vendors/osborne-little.jsonA lib/compose.jsA lib/sweep.js
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, '&').replace(/</g, '<').replace(/>/g, '>'); }
+
+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 <info@designerwallcoverings.com></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">>${(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, '&').replace(/</g, '<').replace(/>/g, '>');
+}
+
+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 →