← back to Sample Followup Sweep

lib/sweep.js

210 lines

'use strict';
const { slugForVid, normVid } = require('./slug-aliases.cjs');   // TK-12117 coverage reconciliation
// 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,
    maxAgeDays = 60, // Steve 2026-08-14: >60 days = dead lead, don't chase
    excludeDisco = true,
    requireNotReceived = true,
    secondRequestPolicy = 'escalate', // 'escalate' (default A) | 'include'
  } = opts;
  // TK-12091 (Steve 2026-09-23): DAY-OF-WEEK-AWARE window. A follow-up fires the day a sample's
  // Entered date hits its 10th CALENDAR day, mapped onto the Tue-Fri run schedule so every landing
  // is chased exactly once. On a TUESDAY run, widen 3 days back so [minAgeDays .. minAgeDays+3]
  // (ages 10..13) sweeps up the Sat(−13)/Sun(−12)/Mon(−11) 10th-day landings plus Tue(−10) itself;
  // Wed/Thu/Fri (and any manual off-day) stay STRICT single-day (age == minAgeDays). An explicit
  // opts.widenDays overrides the auto value (manual recovery net); dedup makes any overlap safe.
  const widenDays = (opts.widenDays != null) ? opts.widenDays : (today.getDay() === 2 ? 3 : 0);

  const followUp = [];
  const escalation = [];
  const skipped = [];

  for (const r of rows) {
    const age = ageDays(r.entered, today);

    if (excludeDisco && r.disco) { skipped.push({ row: r, reason: 'discontinued' }); continue; }
    if (requireNotReceived && r.received) { skipped.push({ row: r, reason: 'already received' }); continue; }

    // Manual adds bypass the age window entirely (explicit human override).
    if (r.manual_add === true) { followUp.push({ ...r, age }); continue; }

    if (age === null) { skipped.push({ row: r, reason: 'no request date' }); continue; }
    // Single-day window: chase ONLY the [minAgeDays .. minAgeDays+widenDays] cohort (default = exactly
    // minAgeDays). Anything younger is not yet due; anything older than the window is NOT part of today's
    // batch (it was that earlier day's batch) — do not fold it back into a cumulative backlog.
    if (age < minAgeDays) { skipped.push({ row: r, reason: `only ${age}d old (<${minAgeDays})` }); continue; }
    if (age > minAgeDays + widenDays) { skipped.push({ row: r, reason: `outside single-day window (${age}d, want exactly ${minAgeDays}${widenDays ? `..${minAgeDays + widenDays}` : ''}d)` }); continue; }
    if (age > maxAgeDays) { skipped.push({ row: r, reason: `dead lead (${age}d > ${maxAgeDays})` }); 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 };
}

// TK-12105 (Steve 2026-09-23): NEVER-FALSE-CHASE guard. Pure decision — given the outstanding
// record's entered date ('today for client') and ALL FileMaker records for that combo sku
// ({ entered, wpSampleSent, letterSent } each), decide whether to SUPPRESS the chase. Two rules,
// suppress if EITHER (checked (a) then (b) so the reason is the most specific):
//   (a) ARRIVAL DEDUP — a sibling record with the SAME entered date (exact string match) already
//       has a non-empty `Date WP Sample Sent` → the sample arrived on a duplicate same-day record.
//       (Catches REID DWRW210156: two 09/08/2026 records, one arrived 09/14. Spares GRD8806: its
//        arrived record is 06/14/2024, a DIFFERENT entered date than the owed 09/10/2026 one.)
//   (b) ALREADY-SENT — any record for the sku has a non-empty `Date Sample Request Letter Sent`
//       (Steve's authoritative "10-day follow-up letter already sent" stamp; the sweep writes it on
//        send via FIELD_2ND_DATE, so this closes the never-resend loop). NOT 'Date Email Sent...'.
// Records with an empty entered date are noise and never trigger (a). Fail-open by design: an empty
// siblings list => not suppressed (the human batch-send gate + 14-day Gmail anti-dup are backstops).
const _ne = (v) => String(v == null ? '' : v).trim() !== '';
function suppressChase(enteredDate, siblings = []) {
  const entered = String(enteredDate == null ? '' : enteredDate).trim();
  if (entered !== '' && siblings.some((s) => _ne(s.wpSampleSent) && String(s.entered == null ? '' : s.entered).trim() === entered))
    return { suppress: true, reason: 'arrived (duplicate record, same entered date)' };
  if (siblings.some((s) => _ne(s.letterSent)))
    return { suppress: true, reason: '10-day letter already sent' };
  return { suppress: false, reason: null };
}

// TK-12091 regression fix (2026-09-24): SINGLE SOURCE OF TRUTH for the production follow-up window.
// Both scheduled-run.mjs (what actually drafts vendor letters) AND coverage-reconcile.mjs (the canary
// that audits coverage) call THIS one function — so the audit can never again model a different window
// than production actually chases. TK-12091 narrowed production's window to a day-of-week band while the
// canary kept auditing [10..60]; the two drifted and hid 15 real stragglers behind a green canary.
// DEFAULT = the full active band [today-maxAge .. today-minAge]. WIN/CATCHUP env overrides preserved.
function followupWindow(today = new Date(), { minAge = 10, maxAge = 60 } = {}) {
  const pad = (n) => String(n).padStart(2, '0');
  const fmtD = (d) => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`;
  if (process.env.WIN) return process.env.WIN;                       // explicit manual override
  const catchup = Number(process.env.CATCHUP) || 0;
  const hi = new Date(today); hi.setDate(hi.getDate() - minAge);     // today-minAge (10-day floor)
  const lo = new Date(today);
  if (catchup > 0) lo.setDate(lo.getDate() - minAge - (catchup - 1)); // manual fixed rolling band
  else lo.setDate(lo.getDate() - maxAge);                            // DEFAULT: full active band floor
  return `${fmtD(lo)}...${fmtD(hi)}`;
}
// Parse an "MM/DD/YYYY...MM/DD/YYYY" window; return { lo, hi } Dates or null.
function parseWindow(win) {
  const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\.\.\.(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(String(win || '').trim());
  if (!m) return null;
  return { lo: new Date(+m[3], +m[1] - 1, +m[2]), hi: new Date(+m[6], +m[4] - 1, +m[5]) };
}
// TK-12091 regression tripwire: does production's ACTUAL window cover the full active band? Returns
// { covers, prodLo, expectedLo, gapDays }. covers===false means production is narrower than [10..60]
// and outstanding in-band memos are unreachable — the exact TK-12091 failure, now a canary FAIL.
function windowCoversActiveBand(today = new Date(), { minAge = 10, maxAge = 60 } = {}) {
  const prod = parseWindow(followupWindow(today, { minAge, maxAge }));
  const expectedLo = new Date(today); expectedLo.setDate(expectedLo.getDate() - maxAge);
  if (!prod) return { covers: false, prodLo: null, expectedLo, gapDays: null, why: 'unparseable production window' };
  const gapDays = Math.round((prod.lo.getTime() - expectedLo.getTime()) / 86400000);
  return { covers: prod.lo.getTime() <= expectedLo.getTime() + 86400000, prodLo: prod.lo, expectedLo, gapDays };
}

// KNOWN UNRESOLVABLE SAMPLES — vendor has no vid/contact anywhere in the fleet, so sweep can't chase.
// These are ACCOUNTED (tracked, WARN-visible) but not actioned. Add SKU::vendor pairs here.
const KNOWN_UNRESOLVABLE = new Set(['DWLC1059::LA Walls']); // TK-12119: LA Walls (no vid anywhere)

// TK-12117 (Steve false-green doctrine): COVERAGE RECONCILIATION. Pure function — given ALL
// in-window 'Report for old memo samples' records + the contacts map + the recently-emailed set,
// bucket EVERY outstanding row (Date WP Sample Sent empty, non-empty entered) into exactly one
// disposition and ASSERT the sweep would account for all of them. A row that is outstanding in
// FileMaker but would appear in NO sweep bucket (e.g. the sweep drops empty-vid rows before
// grouping) = LEAKED = a silent skip = FAIL. This makes "ran successfully while chasing only a
// fraction" impossible: PASS requires the FM population to be fully accounted AND every row in an
// actioned bucket — never PASS merely because the script ran.
//   buckets: chaseable | recipientBusy14d | recipientBusyStale | needsConfirm | guardSuppressed | noVendorMap | noChase
//   actioned (covered) = chaseable + recipientBusy14d + guardSuppressed + noChase
//   uncovered (accounted but NOT actually chased) = noVendorMap + needsConfirm + recipientBusyStale  -> WARN
//   leaked (outstanding but in no bucket) -> FAIL
// TK-12119 (Steve false-green doctrine, deferred-bucket half): a row lands in the recipient-busy path
// ONLY when the vendor's address was emailed within 14d for SOME sku but THIS sku's 10-day letter was
// never sent (a genuinely-sent sku is already caught by suppressChase -> guardSuppressed, before here).
// That bucket is a DELAY, not an action, so it must NOT count as "done" indefinitely: a fresh deferral
// (entered-age <= RECIPIENT_BUSY_STALE_DAYS = the 10-day trigger + one 14-day batching window) is
// acceptable batching and counts as covered; a row still deferred PAST that window has missed its
// chase and escalates to recipientBusyStale -> uncovered -> WARN, so a perpetually-recontacted vendor
// can no longer hide a never-chased sku behind a green canary.
// records: array of { fieldData: {...} } (FileMaker shape) OR plain field objects (fixtures).
const RECIPIENT_BUSY_STALE_DAYS = 24; // 10-day trigger + one full 14-day batching window
function reconcileCoverage(records, { contacts = {}, recentSet = new Set(), runStale = false, today = new Date() } = {}) {
  const norm = (v) => String(v == null ? '' : v).replace(/[\r\n]/g, '').trim();
  // entered is FileMaker MM/DD/YYYY here (ageDays() takes ISO), so parse locally.
  const ageMDY = (mdy) => {
    const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(mdy || '');
    if (!m) return null;
    return Math.floor((today.getTime() - new Date(+m[3], +m[1] - 1, +m[2]).getTime()) / 86400000);
  };
  const fd = (r) => (r && r.fieldData) ? r.fieldData : (r || {});
  // siblings by sku (all in-window records) for the never-false-chase guard
  const siblings = {};
  for (const r of records) {
    const d = fd(r); const sku = norm(d['combo sku']); if (!sku) continue;
    (siblings[sku] = siblings[sku] || []).push({ entered: norm(d['today for client']), wpSampleSent: norm(d['Date WP Sample Sent']), letterSent: norm(d['Date Sample Request Letter Sent']) });
  }
  const b = { chaseable: [], recipientBusy14d: [], recipientBusyStale: [], needsConfirm: [], guardSuppressed: [], noVendorMap: [], noChase: [] };
  const leaked = [];
  let population = 0;
  for (const r of records) {
    const d = fd(r);
    const wp = norm(d['Date WP Sample Sent']);
    const entered = norm(d['today for client']);
    if (wp !== '' || entered === '') continue;                 // only OUTSTANDING in-window rows are the denominator
    population++;
    const sku = norm(d['combo sku']);
    const vid = normVid(d.vid);
    const supplier = norm(d['Supplier']);
    const tag = { vid, sku, entered, mfr: norm(d['Mfr Pattern']) };
    // TK-12119: check known-unresolvable (vendor has no vid anywhere in fleet) BEFORE empty-vid leak
    if (!vid && KNOWN_UNRESOLVABLE.has(`${sku}::${supplier}`)) {
      b.noVendorMap.push({ ...tag, why: 'known unresolvable (no vendor vid in fleet)' });
      continue;
    }
    if (!vid) { leaked.push({ ...tag, why: 'empty vid — sweep drops it before grouping (silent skip)' }); continue; }
    const g = suppressChase(entered, siblings[sku] || []);
    if (g.suppress) { b.guardSuppressed.push({ ...tag, reason: g.reason }); continue; }
    const c = contacts[slugForVid(vid)];
    if (!c) { b.noVendorMap.push(tag); continue; }
    if (c.disposition === 'no-chase') { b.noChase.push(tag); continue; }
    const email = c.sample_email || c.main_email;
    if (!email || !c.account_number) { b.noVendorMap.push({ ...tag, why: 'incomplete contact (no email/account)' }); continue; }
    const recips = String(email).toLowerCase().split(/[,;]\s*/).map((s) => s.trim()).filter(Boolean);
    if (recips.some((e) => recentSet.has(e))) {
      const age = ageMDY(entered);
      const busyTag = { ...tag, ageDays: age };
      if (age != null && age > RECIPIENT_BUSY_STALE_DAYS) b.recipientBusyStale.push(busyTag); // deferred past its window -> WARN
      else b.recipientBusy14d.push(busyTag);                                                  // fresh batching deferral -> covered
      continue;
    }
    if (c.needs_confirm) { b.needsConfirm.push(tag); continue; }
    b.chaseable.push(tag);
  }
  const counts = Object.fromEntries(Object.entries(b).map(([k, v]) => [k, v.length]));
  const accounted = Object.values(counts).reduce((s, n) => s + n, 0);
  const covered = counts.chaseable + counts.recipientBusy14d + counts.guardSuppressed + counts.noChase;
  const uncovered = counts.noVendorMap + counts.needsConfirm + counts.recipientBusyStale;
  const reconciles = (accounted + leaked.length === population);
  let verdict;
  if (leaked.length > 0 || !reconciles) verdict = 'FAIL';       // silent-skip / reconciliation broke
  else if (uncovered > 0 || runStale) verdict = 'WARN';         // real outstanding not actually chased, or run missed
  else verdict = 'PASS';                                        // fully accounted + every row actioned
  return { verdict, status: verdict, population, accounted, covered, uncovered, leaked, reconciles, counts, buckets: b };
}

module.exports = { sweep, ageDays, suppressChase, reconcileCoverage, followupWindow, parseWindow, windowCoversActiveBand };