← back to Sample Followup Sweep

scripts/build-fleet.js

125 lines

'use strict';
// Reads a saved `REPORT ON SAMPLES ORDERED` fm_find result and writes data/fleet.json,
// grouped by vendor (vid), enforcing the 10–60 day window.
const fs = require('fs');
const path = require('path');
// TK-11255: one shared routing table — see lib/slug-aliases.cjs for why.
const { SLUG_ALIASES, normVid, slugForVid } = require('../lib/slug-aliases.cjs');
// Display-name fallback: a slug that carries a real vendor name in contacts.json should
// render THAT name, not the ugly "(vid GREEN)" placeholder, whenever it isn't in NAME.
// Display-only — never touches vid/items/aliasVids/attribution.
let contacts = {};
try { contacts = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'contacts.json'), 'utf8')); } catch { contacts = {}; }

const src = process.argv[2];
if (!src) { console.error('usage: node scripts/build-fleet.js <fm-result.txt>'); process.exit(1); }
const j = JSON.parse(fs.readFileSync(src, 'utf8'));
// "today" drives every age calc. Default = now (correct on every re-run against fresh FM data).
// Optional argv[3] = an explicit as-of date (YYYY-MM-DD) to reproduce a past snapshot.
const today = process.argv[3] ? new Date(process.argv[3] + 'T00:00:00') : new Date();

// Best-effort vid → display name. Editable in the viewer; unknowns show the raw vid.
const NAME = {
  DGD: 'Designers Guild @ Osborne & Little', SCA: 'Scalamandre', KRA: 'Kravet',
  THIB: 'Thibaut', THI: 'Thibaut', PF: 'Pierre Frey', SPN: 'Spoonflower (online — no rep desk)',
  WQ: 'WallQuest / Malibu', SCH: 'Schumacher', SCHUMACHER: 'Schumacher', QUA: 'Quadrille',
  BRE: 'Brewster', BREWSTER: 'Brewster', JOFA: 'Lee Jofa', YOR: 'York', MOR: 'Morris & Co',
  MAYA: 'Maya Romanoff', PJ: 'Phillip Jeffries', RBL: 'Rebel Walls', OSB: 'Osborne & Little',
  'ARTE INTERNATIONAL': 'Arte International', SAND: 'Sanderson', SANDB: 'Sandberg',
};

// Deliberate slug ALIASES and vid normalisation now live in lib/slug-aliases.cjs
// (single source of truth — this table used to be duplicated here and in
// scripts/scheduled-run.mjs, and the two copies had already diverged).

const g = {};
let skippedNoVid = 0, skippedWindow = 0;
for (const r of j.records) {
  const d = r.fieldData;
  const rawvid = (d.vid || '').trim();
  if (!rawvid) { skippedNoVid++; continue; }
  // Merge duplicate vids for the same vendor (e.g. SCHUMACHER + SCH → one Schumacher row).
  const VID_ALIAS = { SCHUMACHER: 'SCH' };
  const vid = VID_ALIAS[normVid(rawvid)] || normVid(rawvid);
  const dt = d['today for client'];
  const age = dt ? Math.floor((today - new Date(dt)) / 86400000) : null;
  if (age === null || age < 10 || age > 60) { skippedWindow++; continue; }
  const natural = vid.toLowerCase().replace(/[^a-z0-9]+/g, '-');   // pre-alias form, used by the guard below
  // A declared alias routes this vid's memos to ANOTHER vendor's desk (Anna French
  // -> Thibaut). Applied HERE, not after grouping, so the routing is the same
  // whether or not the aliased vid happens to have items in this window.
  const slug = slugForVid(vid);
  // Two DISTINCT vids landing on one slug: the old code merged them silently via
  // `g[slug] = g[slug] || …`, attributing one vendor's outstanding samples to the
  // other. Intentional only when declared above; otherwise refuse to build.
  if (g[slug] && String(g[slug].vid).toUpperCase() !== vid && SLUG_ALIASES[vid] !== slug) {
    console.error(`FATAL: vids '${g[slug].vid}' and '${vid}' both derive slug '${slug}'.`);
    console.error(`Their samples would be silently merged into one vendor. If that routing is`);
    console.error(`intentional add   '${vid}': '${slug}',   to SLUG_ALIASES. Refusing to write fleet.json.`);
    process.exit(1);
  }
  g[slug] = g[slug] || { slug, vid, name: NAME[vid] || (contacts[slug] && contacts[slug].name) || `(vid ${rawvid})`, items: [] };
  // Keep the aliased vid visible so stamping/reply-matching don't lose it.
  if (String(g[slug].vid).toUpperCase() !== vid) {
    g[slug].aliasVids = g[slug].aliasVids || [];
    if (!g[slug].aliasVids.includes(vid)) g[slug].aliasVids.push(vid);
  }
  g[slug].items.push({
    mfr: (d['Mfr Pattern'] || '').replace(/\r/g, '').replace(/\($/, '').trim(),
    dw: (d['combo sku'] || '').trim(),
    client: (d['Clients::Company'] || d['company for client fileS'] || '').replace(/\r/g, '').trim(),
    age, req: dt,
  });
}

// Re-request detection: same DW# (or pattern) + same client appearing more than once
// = that sample was requested again. Earliest date = initial request.
const vendors = Object.values(g).map(v => {
  const byKey = {};
  for (const it of v.items) { const k = (it.dw || it.mfr) + '|' + it.client; (byKey[k] = byKey[k] || []).push(it); }
  for (const k in byKey) {
    const arr = byKey[k];
    const initial = arr.map(x => x.req).filter(Boolean).sort((a, b) => new Date(a) - new Date(b))[0] || null;
    const reReq = arr.length > 1;
    arr.forEach(x => { x.reRequested = reReq; x.timesRequested = arr.length; x.initialReq = initial || x.req; });
  }
  const items = v.items.sort((a, b) => b.age - a.age);
  return { ...v, items, count: items.length, reReqCount: items.filter(x => x.reRequested).length };
}).sort((a, b) => b.count - a.count);

// Emit the declared alias stubs unless that vid already has a real group of its
// own, so a rebuild cannot drop routing that a hand-edit used to supply.
const aliasStubs = Object.entries(SLUG_ALIASES)
  .filter(([vid]) => !vendors.some((v) => String(v.vid).toUpperCase() === vid.toUpperCase()))
  .map(([vid, slug]) => ({ slug, vid, name: NAME[vid] || (contacts[slug] && contacts[slug].name) || `(vid ${vid})`, items: [], count: 0, reReqCount: 0 }));
const allVendors = [...vendors, ...aliasStubs];

// Build-time guard: a slug may repeat ONLY through a declared alias. An undeclared
// collision means new source data or a bad edit, and must NOT ship -- a consumer
// doing a last-wins {slug: vid} cast silently drops the extra rows (that is exactly
// how a phantom "drift" finding was manufactured during the TK-11255 audit).
const bySlug = {};
for (const v of allVendors) (bySlug[v.slug] = bySlug[v.slug] || []).push(String(v.vid).toUpperCase());
for (const [slug, vids] of Object.entries(bySlug)) {
  if (vids.length < 2) continue;
  for (const vd of vids) {
    const natural = vd.toLowerCase().replace(/[^a-z0-9]+/g, '-') === slug;
    if (!natural && SLUG_ALIASES[vd] !== slug) {
      console.error(`FATAL: undeclared duplicate slug '${slug}' for vid '${vd}' (slug shared by: ${vids.join(', ')}).`);
      console.error(`If that routing is intentional add   '${vd}': '${slug}',   to SLUG_ALIASES. Refusing to write fleet.json.`);
      process.exit(1);
    }
  }
}

const out = {
  generated: '2026-08-14',
  window: '10–60 days (>60 = dead lead, excluded)',
  totalItems: allVendors.reduce((s, v) => s + v.count, 0),
  skippedNoVid, skippedWindow,
  vendors: allVendors,
};
fs.writeFileSync(path.join(__dirname, '..', 'data', 'fleet.json'), JSON.stringify(out, null, 2));
console.log(`vendors=${vendors.length} items=${out.totalItems} (skipped: no-vid ${skippedNoVid}, out-of-window ${skippedWindow})`);
vendors.forEach(v => console.log(`  ${v.slug.padEnd(14)} ${String(v.count).padStart(2)}  ${v.name}`));