More bugfixes
All checks were successful
CI / test (push) Successful in 53s
CI / test-analytics (push) Successful in 1m59s
CI / build-api (push) Successful in 40s
CI / build-frontend (push) Successful in 1m55s
CI / build-analytics (push) Successful in 2m46s

This commit is contained in:
2026-06-27 15:53:32 +02:00
parent 980233840e
commit cf54ccc7b3
5 changed files with 39 additions and 26 deletions

View File

@@ -298,9 +298,9 @@ func (h *Handler) ExportAWSalesHistory(w http.ResponseWriter, r *http.Request) {
} }
cols := []export.Column{ cols := []export.Column{
{Key: "date", Label: "Date"}, {Key: "date", Label: "Date"},
{Key: "total_revenue", Label: "Total Revenue"}, {Key: "revenue", Label: "Revenue"},
{Key: "total_orders", Label: "Total Orders"}, {Key: "cost", Label: "Cost"},
{Key: "avg_order_value", Label: "Avg Order Value"}, {Key: "quantity", Label: "Quantity"},
} }
b, err := export.ToXLSXBytes(r.Context(), "Sales History", cols, toMaps(data)) b, err := export.ToXLSXBytes(r.Context(), "Sales History", cols, toMaps(data))
if err != nil { if err != nil {
@@ -342,10 +342,12 @@ func (h *Handler) ExportAWRepScores(w http.ResponseWriter, r *http.Request) {
} }
cols := []export.Column{ cols := []export.Column{
{Key: "rep_name", Label: "Sales Rep"}, {Key: "rep_name", Label: "Sales Rep"},
{Key: "total_revenue", Label: "Total Revenue"}, {Key: "territory", Label: "Territory"},
{Key: "total_orders", Label: "Total Orders"}, {Key: "revenue", Label: "Revenue"},
{Key: "avg_order_value", Label: "Avg Order Value"}, {Key: "orders", Label: "Orders"},
{Key: "performance_score", Label: "Performance Score"}, {Key: "avg_deal_size", Label: "Avg Deal Size"},
{Key: "margin_pct", Label: "Margin %"},
{Key: "score", Label: "Performance Score"},
} }
b, err := export.ToXLSXBytes(r.Context(), "Rep Scores", cols, toMaps(data)) b, err := export.ToXLSXBytes(r.Context(), "Rep Scores", cols, toMaps(data))
if err != nil { if err != nil {
@@ -366,8 +368,10 @@ func (h *Handler) ExportAWProductDemand(w http.ResponseWriter, r *http.Request)
cols := []export.Column{ cols := []export.Column{
{Key: "product_name", Label: "Product"}, {Key: "product_name", Label: "Product"},
{Key: "category", Label: "Category"}, {Key: "category", Label: "Category"},
{Key: "total_quantity", Label: "Total Quantity"}, {Key: "quantity", Label: "Quantity"},
{Key: "total_revenue", Label: "Total Revenue"}, {Key: "revenue", Label: "Revenue"},
{Key: "orders", Label: "Orders"},
{Key: "margin_pct", Label: "Margin %"},
{Key: "demand_score", Label: "Demand Score"}, {Key: "demand_score", Label: "Demand Score"},
} }
b, err := export.ToXLSXBytes(r.Context(), "Product Demand", cols, toMaps(data)) b, err := export.ToXLSXBytes(r.Context(), "Product Demand", cols, toMaps(data))
@@ -411,10 +415,11 @@ func (h *Handler) ExportWWISupplierScores(w http.ResponseWriter, r *http.Request
} }
cols := []export.Column{ cols := []export.Column{
{Key: "supplier_name", Label: "Supplier"}, {Key: "supplier_name", Label: "Supplier"},
{Key: "category", Label: "Category"},
{Key: "total_orders", Label: "Total Orders"}, {Key: "total_orders", Label: "Total Orders"},
{Key: "on_time_delivery_rate", Label: "On-Time Delivery Rate"}, {Key: "fill_rate_pct", Label: "Fill Rate %"},
{Key: "avg_lead_time_days", Label: "Avg Lead Time (Days)"}, {Key: "finalization_rate_pct", Label: "Finalization Rate %"},
{Key: "performance_score", Label: "Performance Score"}, {Key: "score", Label: "Performance Score"},
} }
b, err := export.ToXLSXBytes(r.Context(), "Supplier Scores", cols, toMaps(data)) b, err := export.ToXLSXBytes(r.Context(), "Supplier Scores", cols, toMaps(data))
if err != nil { if err != nil {

View File

@@ -68,14 +68,14 @@ export const getWWIJobs = (limit = 50) =>
get<JobExecution[]>(`/api/jobs/wwi?limit=${limit}`, "frontend.jobs.wwi"); get<JobExecution[]>(`/api/jobs/wwi?limit=${limit}`, "frontend.jobs.wwi");
export const triggerAWJob = (jobName: string) => export const triggerAWJob = (jobName: string) =>
post<{ triggered: boolean; job_name: string }>( post<{ status: string; job: string }>(
`/api/jobs/aw/${jobName}/trigger`, `/api/aw/jobs/${jobName}/trigger`,
"frontend.jobs.aw.trigger", "frontend.jobs.aw.trigger",
); );
export const triggerWWIJob = (jobName: string) => export const triggerWWIJob = (jobName: string) =>
post<{ triggered: boolean; job_name: string }>( post<{ status: string; job: string }>(
`/api/jobs/wwi/${jobName}/trigger`, `/api/wwi/jobs/${jobName}/trigger`,
"frontend.jobs.wwi.trigger", "frontend.jobs.wwi.trigger",
); );

View File

@@ -126,11 +126,12 @@ export type WWIScenario = {
export type AWAnomalyPoint = { export type AWAnomalyPoint = {
date: string; date: string;
revenue: number; revenue: number;
rolling_mean: number; // null during the rolling-window warm-up period
lower_band: number; rolling_mean: number | null;
upper_band: number; lower_band: number | null;
upper_band: number | null;
is_anomaly: boolean; is_anomaly: boolean;
z_score: number; z_score: number | null;
direction: "high" | "low" | null; direction: "high" | "low" | null;
}; };

View File

@@ -72,8 +72,16 @@ export default function AnomalyDetection() {
anomalyRevenue: p.is_anomaly ? p.revenue : null, anomalyRevenue: p.is_anomaly ? p.revenue : null,
})); }));
const domainMin = Math.min(...series.map((p) => p.lower_band)) * 0.95; // Bands are null during the rolling-window warm-up; ignore those and fall
const domainMax = Math.max(...series.map((p) => p.upper_band)) * 1.05; // back to revenue so anomaly points are never clipped off the chart.
const lows = series
.map((p) => (p.lower_band ?? p.revenue))
.filter((v): v is number => v != null);
const highs = series
.map((p) => (p.upper_band ?? p.revenue))
.filter((v): v is number => v != null);
const domainMin = lows.length ? Math.min(...lows) * 0.95 : 0;
const domainMax = highs.length ? Math.max(...highs) * 1.05 : 0;
return ( return (
<div className="flex flex-col gap-6 max-w-[1100px] mx-auto"> <div className="flex flex-col gap-6 max-w-[1100px] mx-auto">
@@ -228,16 +236,16 @@ export default function AnomalyDetection() {
<td className="font-mono text-sm">{p.date}</td> <td className="font-mono text-sm">{p.date}</td>
<td>{money.format(p.revenue)}</td> <td>{money.format(p.revenue)}</td>
<td className="text-[rgba(233,244,255,0.5)]"> <td className="text-[rgba(233,244,255,0.5)]">
{money.format(p.rolling_mean)} {p.rolling_mean != null ? money.format(p.rolling_mean) : "—"}
</td> </td>
<td <td
className={ className={
Math.abs(p.z_score) > 3 (p.z_score != null && Math.abs(p.z_score) > 3)
? "text-red-400 font-semibold" ? "text-red-400 font-semibold"
: "text-amber-400" : "text-amber-400"
} }
> >
{p.z_score.toFixed(2)} {p.z_score != null ? p.z_score.toFixed(2) : "—"}
</td> </td>
<td>{directionBadge(p.direction)}</td> <td>{directionBadge(p.direction)}</td>
</tr> </tr>

View File

@@ -13,7 +13,6 @@ const AW_JOBS = [
const WWI_JOBS = [ const WWI_JOBS = [
{ id: "reorder", label: "Reorder Recommendations" }, { id: "reorder", label: "Reorder Recommendations" },
{ id: "supplier_scores", label: "Supplier Scores" }, { id: "supplier_scores", label: "Supplier Scores" },
{ id: "events", label: "Business Events" },
{ id: "data_quality", label: "Data Quality" }, { id: "data_quality", label: "Data Quality" },
]; ];