| |
|
|
| const $ = (id) => document.getElementById(id); |
| let chart = null; |
|
|
| async function getJSON(url) { |
| const r = await fetch(url); |
| if (!r.ok) throw new Error(`${url} -> ${r.status}`); |
| return r.json(); |
| } |
|
|
| function params() { |
| return new URLSearchParams({ |
| current_inventory: $("inventory").value || "0", |
| service_level: $("service").value, |
| lead_time_days: $("leadtime").value || "7", |
| }); |
| } |
|
|
| async function loadSkus() { |
| const { stores, items } = await getJSON("/api/skus"); |
| $("store").innerHTML = stores.map((s) => `<option>${s}</option>`).join(""); |
| $("item").innerHTML = items.map((i) => `<option>${i}</option>`).join(""); |
| if (items.includes("FOODS_3_090")) $("item").value = "FOODS_3_090"; |
| } |
|
|
| |
|
|
| |
| const forecastDivider = { |
| id: "forecastDivider", |
| afterDraw(c, _args, opts) { |
| const i = opts.splitIndex; |
| if (!i) return; |
| const x = c.scales.x.getPixelForValue(i - 0.5); |
| const { top, bottom } = c.chartArea; |
| const g = c.ctx; |
| g.save(); |
| g.strokeStyle = "rgba(245,166,35,0.55)"; |
| g.setLineDash([4, 4]); |
| g.beginPath(); g.moveTo(x, top); g.lineTo(x, bottom); g.stroke(); |
| g.setLineDash([]); |
| g.font = "600 11px 'IBM Plex Sans', sans-serif"; |
| g.fillStyle = "rgba(163,148,111,0.9)"; |
| g.textAlign = "right"; |
| g.fillText("the past", x - 8, top + 14); |
| g.textAlign = "left"; |
| g.fillStyle = "rgba(245,166,35,0.95)"; |
| g.fillText("our forecast →", x + 8, top + 14); |
| g.restore(); |
| }, |
| }; |
| Chart.register(forecastDivider); |
|
|
| function renderChart(fc) { |
| const histDates = fc.history_dates || []; |
| const labels = histDates.concat(fc.dates).map((d) => d.slice(5)); |
| const pad = new Array(histDates.length).fill(null); |
|
|
| const css = getComputedStyle(document.documentElement); |
| const ink = css.getPropertyValue("--ink").trim(); |
| const amber = css.getPropertyValue("--amber").trim(); |
| const muted = css.getPropertyValue("--muted").trim(); |
|
|
| if (chart) chart.destroy(); |
| chart = new Chart($("chart"), { |
| type: "line", |
| data: { |
| labels, |
| datasets: [ |
| { label: "what actually sold", data: (fc.history_actual || []).concat(fc.actual), |
| borderColor: ink, borderWidth: 1.5, pointRadius: 0, tension: 0.25 }, |
| { label: "what we predicted", data: pad.concat(fc.p50), |
| borderColor: amber, borderWidth: 2, borderDash: [6, 4], pointRadius: 0, tension: 0.25 }, |
| { label: "low", data: pad.concat(fc.p10), |
| borderColor: "rgba(245,166,35,0.25)", borderWidth: 1, pointRadius: 0, tension: 0.25 }, |
| { label: "likely range", data: pad.concat(fc.p90), |
| borderColor: "rgba(245,166,35,0.25)", backgroundColor: "rgba(245,166,35,0.16)", |
| borderWidth: 1, pointRadius: 0, tension: 0.25, fill: "-1" }, |
| ], |
| }, |
| options: { |
| responsive: true, maintainAspectRatio: false, |
| interaction: { mode: "index", intersect: false }, |
| scales: { |
| x: { ticks: { color: muted, maxTicksLimit: 9, font: { size: 11 } }, grid: { display: false } }, |
| y: { beginAtZero: true, ticks: { color: muted, font: { size: 11 } }, |
| grid: { color: "rgba(53,44,24,0.5)" }, |
| title: { display: true, text: "units sold per day", color: muted } }, |
| }, |
| plugins: { |
| legend: { display: false }, |
| tooltip: { filter: (i) => i.dataset.label !== "low" }, |
| forecastDivider: { splitIndex: histDates.length }, |
| }, |
| }, |
| }); |
| } |
|
|
| |
|
|
| function setChip(status) { |
| const chip = $("status-chip"); |
| const map = { |
| order_now: ["red", "Order today"], |
| order_soon: ["amber", "Order soon"], |
| ok: ["green", "You're covered"], |
| }; |
| const [cls, text] = map[status]; |
| chip.className = `chip ${cls}`; |
| chip.textContent = text; |
| } |
|
|
| function verdictText(status, lead) { |
| if (status === "ok") |
| return `No order needed. Your current stock comfortably covers the next ${lead} days.`; |
| if (status === "order_soon") |
| return `You're below the safe level. Order in the next day or two to stay ahead.`; |
| return `Your stock will likely run out before a new delivery could arrive (${lead} days). Order now.`; |
| } |
|
|
| async function refreshSeries() { |
| const store = $("store").value, item = $("item").value; |
| if (!store || !item) return; |
| const lead = parseInt($("leadtime").value || "7", 10); |
| const inv = parseFloat($("inventory").value || "0"); |
|
|
| const [fc, rec] = await Promise.all([ |
| getJSON(`/api/forecast/${store}/${item}`), |
| getJSON(`/api/recommendation/${store}/${item}?${params()}`), |
| ]); |
|
|
| renderChart(fc); |
|
|
| const leadDemand = rec.avg_daily_demand * lead; |
| let status = "ok"; |
| if (rec.suggested_order_qty > 0) status = inv <= leadDemand ? "order_now" : "order_soon"; |
| setChip(status); |
|
|
| $("order-qty").textContent = Math.ceil(rec.suggested_order_qty); |
| $("verdict").textContent = verdictText(status, lead); |
| $("r-sell").textContent = Math.round(leadDemand); |
| $("r-cushion").textContent = Math.round(rec.safety_stock); |
| $("r-have").textContent = Math.round(inv); |
|
|
| $("n-rop").textContent = rec.reorder_point.toFixed(1); |
| $("n-ss").textContent = rec.safety_stock.toFixed(1); |
| $("n-std").textContent = rec.demand_std.toFixed(2); |
| $("n-sl").textContent = `${Math.round(rec.service_level * 100)}%`; |
| } |
|
|
| |
|
|
| async function refreshActionList() { |
| const store = $("store").value; |
| if (!store) return; |
| const al = await getJSON(`/api/action_list/${store}?${params()}&limit=25`); |
|
|
| $("al-store").textContent = store; |
| $("al-inv").textContent = al.assumptions.current_inventory_per_item; |
|
|
| const c = al.counts; |
| $("al-counts").innerHTML = [ |
| ["red", "order_now", "need ordering today"], |
| ["amber", "order_soon", "order soon"], |
| ["green", "ok", "covered"], |
| ].map(([cls, k, label]) => |
| `<span class="countpill ${cls}"><b>${(c[k] || 0).toLocaleString()}</b> ${label}</span>` |
| ).join("") + `<span class="countpill">of <b>${al.total_items.toLocaleString()}</b> products</span>`; |
|
|
| const dot = { order_now: "red", order_soon: "amber", ok: "green" }; |
| const word = { order_now: "today", order_soon: "soon", ok: "covered" }; |
| $("al-body").innerHTML = al.items.map((r) => ` |
| <tr data-item="${r.item_id}"> |
| <td>${r.item_id}</td> |
| <td><span class="dot ${dot[r.status]}"></span>${word[r.status]}</td> |
| <td class="num">${Math.ceil(r.suggested_order_qty)}</td> |
| <td class="num">${Number(r.expected_daily_sales).toFixed(1)}</td> |
| <td class="num">${Math.round(r.safety_cushion)}</td> |
| </tr>`).join(""); |
|
|
| $("al-body").querySelectorAll("tr").forEach((tr) => |
| tr.addEventListener("click", () => { |
| $("item").value = tr.dataset.item; |
| refreshSeries(); |
| window.scrollTo({ top: 0, behavior: "smooth" }); |
| }) |
| ); |
| } |
|
|
| |
|
|
| async function loadProof() { |
| let bt; |
| try { bt = await getJSON("/api/backtest_summary"); } |
| catch { $("proof-note").textContent = "Backtest results not available."; return; } |
|
|
| const n = bt.policies.naive, r = bt.policies.recommended; |
| const drop = ((n.stockout_rate_pct - r.stockout_rate_pct) / n.stockout_rate_pct * 100).toFixed(0); |
| $("proofgrid").innerHTML = ` |
| <div class="prooftile"> |
| <h3>Gut-feel manager — empty-shelf days</h3> |
| <div class="val">${n.stockout_rate_pct.toFixed(1)}%</div> |
| <div class="sub">holding ${n.avg_holding_units.toFixed(1)} units on average</div> |
| </div> |
| <div class="prooftile winner"> |
| <h3>RestockIQ manager — empty-shelf days</h3> |
| <div class="val good">${r.stockout_rate_pct.toFixed(1)}%</div> |
| <div class="sub">holding ${r.avg_holding_units.toFixed(1)} units on average</div> |
| </div> |
| <div class="prooftile winner"> |
| <h3>Result</h3> |
| <div class="val good">−${drop}%</div> |
| <div class="sub">fewer empty-shelf days, for ~${(r.avg_holding_units - n.avg_holding_units).toFixed(1)} extra units held</div> |
| </div>`; |
| $("proof-note").textContent = |
| `Replayed over ${bt.n_series.toLocaleString()} products × ${bt.horizon_days} days (${bt.holdout}), ` + |
| `${bt.lead_time_days}-day delivery gap, ${Math.round(bt.service_level * 100)}% protection target. ` + |
| `Identical simulation rules for both sides; only the reorder logic differs.`; |
| } |
|
|
| |
|
|
| function wireSteppers() { |
| document.querySelectorAll(".stepper button").forEach((b) => |
| b.addEventListener("click", () => { |
| const input = $(b.dataset.for); |
| const v = (parseFloat(input.value) || 0) + parseInt(b.dataset.step, 10); |
| const min = input.min !== "" ? parseFloat(input.min) : -Infinity; |
| const max = input.max !== "" ? parseFloat(input.max) : Infinity; |
| input.value = Math.min(max, Math.max(min, v)); |
| input.dispatchEvent(new Event("change")); |
| }) |
| ); |
| } |
|
|
| async function init() { |
| wireSteppers(); |
| await loadSkus(); |
| await Promise.all([refreshSeries(), refreshActionList(), loadProof()]); |
| $("store").addEventListener("change", () => { refreshSeries(); refreshActionList(); }); |
| $("item").addEventListener("change", refreshSeries); |
| for (const id of ["inventory", "service", "leadtime"]) { |
| $(id).addEventListener("change", () => { refreshSeries(); refreshActionList(); }); |
| } |
| } |
|
|
| init().catch((e) => { |
| document.querySelector("main").insertAdjacentHTML( |
| "afterbegin", |
| `<div class="card" style="border-color:#ff7a5c;color:#ff7a5c">Failed to load: ${e.message}</div>` |
| ); |
| }); |
|
|