← 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}`));