164 lines
7.2 KiB
JavaScript
164 lines
7.2 KiB
JavaScript
|
|
import { readFileSync, watchFile, existsSync } from "fs";
|
||
|
|
|
||
|
|
const OUTPUT_FILE = "output/scraped-pos.json";
|
||
|
|
const PROGRESS_FILE = "output/progress.json";
|
||
|
|
const POLL_MS = 5000;
|
||
|
|
|
||
|
|
let lastCount = 0;
|
||
|
|
|
||
|
|
function classify(description) {
|
||
|
|
if (!description) return null;
|
||
|
|
const d = description.toLowerCase();
|
||
|
|
if (!d.includes("plumbing")) return null;
|
||
|
|
if (d.includes("plumbing pm") || d.includes("pm and corrective")) return "PM";
|
||
|
|
return "Reactive";
|
||
|
|
}
|
||
|
|
|
||
|
|
function extractSite(record) {
|
||
|
|
if (record.site_code) return record.site_code;
|
||
|
|
for (const li of record.line_items || []) {
|
||
|
|
if (!li.description) continue;
|
||
|
|
const m = li.description.match(/^([A-Z]{1,4}[0-9]{1,2}|[A-Z]{4,5})\b/);
|
||
|
|
if (m) return m[1];
|
||
|
|
}
|
||
|
|
if (record.ship_to_raw) {
|
||
|
|
const m = record.ship_to_raw.match(/\(([A-Z0-9]{3,5})\)/);
|
||
|
|
if (m) return m[1];
|
||
|
|
}
|
||
|
|
return "Unknown";
|
||
|
|
}
|
||
|
|
|
||
|
|
function analyze() {
|
||
|
|
if (!existsSync(OUTPUT_FILE)) {
|
||
|
|
console.log("Waiting for scrape output...");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let data;
|
||
|
|
try {
|
||
|
|
data = JSON.parse(readFileSync(OUTPUT_FILE, "utf-8"));
|
||
|
|
} catch {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (data.length === lastCount) return;
|
||
|
|
lastCount = data.length;
|
||
|
|
|
||
|
|
let progress = {};
|
||
|
|
if (existsSync(PROGRESS_FILE)) {
|
||
|
|
try {
|
||
|
|
progress = JSON.parse(readFileSync(PROGRESS_FILE, "utf-8"));
|
||
|
|
} catch {}
|
||
|
|
}
|
||
|
|
|
||
|
|
const sites = {};
|
||
|
|
let pmTotal = 0;
|
||
|
|
let reactiveTotal = 0;
|
||
|
|
let pmCount = 0;
|
||
|
|
let reactiveCount = 0;
|
||
|
|
let nonPlumbing = 0;
|
||
|
|
const plumbingPOs = [];
|
||
|
|
|
||
|
|
for (const record of data) {
|
||
|
|
let isPlumbing = false;
|
||
|
|
let category = null;
|
||
|
|
|
||
|
|
for (const li of record.line_items || []) {
|
||
|
|
const cat = classify(li.description);
|
||
|
|
if (cat) {
|
||
|
|
isPlumbing = true;
|
||
|
|
category = cat;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!isPlumbing) {
|
||
|
|
nonPlumbing++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const site = extractSite(record);
|
||
|
|
const amount = parseFloat(
|
||
|
|
(record.line_items[0]?.total || "0").replace(/,/g, "")
|
||
|
|
);
|
||
|
|
|
||
|
|
if (!sites[site]) sites[site] = { pm: 0, reactive: 0, pmCount: 0, reactiveCount: 0 };
|
||
|
|
|
||
|
|
if (category === "PM") {
|
||
|
|
sites[site].pm += amount;
|
||
|
|
sites[site].pmCount++;
|
||
|
|
pmTotal += amount;
|
||
|
|
pmCount++;
|
||
|
|
} else {
|
||
|
|
sites[site].reactive += amount;
|
||
|
|
sites[site].reactiveCount++;
|
||
|
|
reactiveTotal += amount;
|
||
|
|
reactiveCount++;
|
||
|
|
}
|
||
|
|
|
||
|
|
plumbingPOs.push({ po: record.po_number, site, category, amount });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clear screen and print dashboard
|
||
|
|
console.clear();
|
||
|
|
console.log("╔══════════════════════════════════════════════════════════════╗");
|
||
|
|
console.log("║ COUPA PO SCRAPER — LIVE PLUMBING MONITOR ║");
|
||
|
|
console.log("╚══════════════════════════════════════════════════════════════╝");
|
||
|
|
console.log();
|
||
|
|
console.log(` Scraped: ${data.length} / 4,192 POs (${Object.keys(progress).length} completed)`);
|
||
|
|
console.log(` Plumbing: ${pmCount + reactiveCount} POs | Non-plumbing: ${nonPlumbing}`);
|
||
|
|
console.log();
|
||
|
|
console.log(" ┌──────────────┬──────────────────┬──────────────────┬──────────────────┐");
|
||
|
|
console.log(" │ Category │ PO Count │ Spend │ Avg per PO │");
|
||
|
|
console.log(" ├──────────────┼──────────────────┼──────────────────┼──────────────────┤");
|
||
|
|
console.log(
|
||
|
|
` │ PM │ ${String(pmCount).padStart(16)} │ $${pmTotal.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).padStart(15)} │ $${(pmCount ? pmTotal / pmCount : 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).padStart(15)} │`
|
||
|
|
);
|
||
|
|
console.log(
|
||
|
|
` │ Reactive │ ${String(reactiveCount).padStart(16)} │ $${reactiveTotal.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).padStart(15)} │ $${(reactiveCount ? reactiveTotal / reactiveCount : 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).padStart(15)} │`
|
||
|
|
);
|
||
|
|
const total = pmTotal + reactiveTotal;
|
||
|
|
const totalCount = pmCount + reactiveCount;
|
||
|
|
console.log(" ├──────────────┼──────────────────┼──────────────────┼──────────────────┤");
|
||
|
|
console.log(
|
||
|
|
` │ TOTAL │ ${String(totalCount).padStart(16)} │ $${total.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).padStart(15)} │ $${(totalCount ? total / totalCount : 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).padStart(15)} │`
|
||
|
|
);
|
||
|
|
console.log(" └──────────────┴──────────────────┴──────────────────┴──────────────────┘");
|
||
|
|
|
||
|
|
// Top sites
|
||
|
|
const sortedSites = Object.entries(sites)
|
||
|
|
.map(([site, d]) => ({ site, total: d.pm + d.reactive, ...d }))
|
||
|
|
.sort((a, b) => b.total - a.total);
|
||
|
|
|
||
|
|
if (sortedSites.length > 0) {
|
||
|
|
console.log();
|
||
|
|
console.log(" Top 15 Sites:");
|
||
|
|
console.log(" ┌──────────┬──────────────────┬──────────────────┬──────────────────┐");
|
||
|
|
console.log(" │ Site │ PM Spend │ Reactive Spend │ Total │");
|
||
|
|
console.log(" ├──────────┼──────────────────┼──────────────────┼──────────────────┤");
|
||
|
|
for (const s of sortedSites.slice(0, 15)) {
|
||
|
|
console.log(
|
||
|
|
` │ ${s.site.padEnd(8)} │ $${s.pm.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).padStart(15)} │ $${s.reactive.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).padStart(15)} │ $${s.total.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).padStart(15)} │`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
console.log(" └──────────┴──────────────────┴──────────────────┴──────────────────┘");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Last 5 plumbing POs scraped
|
||
|
|
if (plumbingPOs.length > 0) {
|
||
|
|
console.log();
|
||
|
|
console.log(" Last 5 plumbing POs:");
|
||
|
|
for (const p of plumbingPOs.slice(-5)) {
|
||
|
|
console.log(` ${p.po} ${p.site.padEnd(8)} ${p.category.padEnd(10)} $${p.amount.toLocaleString()}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log();
|
||
|
|
console.log(` Last updated: ${new Date().toLocaleTimeString()}`);
|
||
|
|
console.log(" Press Ctrl+C to stop monitoring.");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Run immediately, then poll
|
||
|
|
analyze();
|
||
|
|
setInterval(analyze, POLL_MS);
|