Push the rest

This commit is contained in:
2026-05-11 10:58:46 +02:00
parent adb5c1a439
commit 0031caf16c
94 changed files with 11777 additions and 3474 deletions

View File

@@ -0,0 +1,447 @@
package handler
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"github.com/jackc/pgx/v5/pgxpool"
"otel-bi-analytics/internal/analytics"
"otel-bi-analytics/internal/export"
"otel-bi-analytics/internal/scheduler"
)
type Handler struct {
awDB *sql.DB
wwiDB *sql.DB
pgPool *pgxpool.Pool
sched *scheduler.Scheduler
defaultTopN int
defaultForecastDays int
defaultHistoryDays int
}
func New(awDB, wwiDB *sql.DB, pgPool *pgxpool.Pool, sched *scheduler.Scheduler, topN, forecastDays, historyDays int) *Handler {
return &Handler{
awDB: awDB,
wwiDB: wwiDB,
pgPool: pgPool,
sched: sched,
defaultTopN: topN,
defaultForecastDays: forecastDays,
defaultHistoryDays: historyDays,
}
}
// RegisterRoutes wires all routes into the given mux (Go 1.22 method+path syntax).
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /health", h.Health)
mux.HandleFunc("GET /aw/sales/kpis", h.AWKPIs)
mux.HandleFunc("GET /aw/sales/history", h.AWHistory)
mux.HandleFunc("GET /aw/sales/forecast", h.AWForecast)
mux.HandleFunc("GET /aw/reps/scores", h.AWRepScores)
mux.HandleFunc("GET /aw/products/demand", h.AWProductDemand)
mux.HandleFunc("GET /aw/anomalies", h.AWAnomalies)
mux.HandleFunc("GET /aw/data-quality", h.AWDataQuality)
mux.HandleFunc("GET /aw/export/sales-history", h.ExportAWSalesHistory)
mux.HandleFunc("GET /aw/export/sales-forecast", h.ExportAWSalesForecast)
mux.HandleFunc("GET /aw/export/rep-scores", h.ExportAWRepScores)
mux.HandleFunc("GET /aw/export/product-demand", h.ExportAWProductDemand)
mux.HandleFunc("GET /wwi/sales/kpis", h.WWIKPIs)
mux.HandleFunc("GET /wwi/stock/recommendations", h.WWIReorderRecommendations)
mux.HandleFunc("GET /wwi/suppliers/scores", h.WWISupplierScores)
mux.HandleFunc("POST /wwi/scenarios", h.WWIWhatIfScenario)
mux.HandleFunc("GET /wwi/data-quality", h.WWIDataQuality)
mux.HandleFunc("GET /wwi/export/stock-recommendations", h.ExportWWIStockRecommendations)
mux.HandleFunc("GET /wwi/export/supplier-scores", h.ExportWWISupplierScores)
mux.HandleFunc("POST /scheduler/aw/{job_name}/trigger", h.TriggerAWJob)
mux.HandleFunc("POST /scheduler/wwi/{job_name}/trigger", h.TriggerWWIJob)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("json encode failed", "err", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func queryInt(r *http.Request, key string, defaultVal int) int {
s := r.URL.Query().Get(key)
if s == "" {
return defaultVal
}
v, err := strconv.Atoi(s)
if err != nil || v <= 0 {
return defaultVal
}
return v
}
func writeXLSX(w http.ResponseWriter, filename string, rowCount int, data []byte) {
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("X-Row-Count", strconv.Itoa(rowCount))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func toMaps(v any) []map[string]any {
b, _ := json.Marshal(v)
var out []map[string]any
_ = json.Unmarshal(b, &out)
if out == nil {
out = []map[string]any{}
}
return out
}
// ---------------------------------------------------------------------------
// Analytics handlers
// ---------------------------------------------------------------------------
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) AWKPIs(w http.ResponseWriter, r *http.Request) {
result, err := analytics.AWGetSalesKPIs(r.Context(), h.awDB)
if err != nil {
slog.Error("AWGetSalesKPIs", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) AWHistory(w http.ResponseWriter, r *http.Request) {
daysBack := queryInt(r, "days_back", h.defaultHistoryDays)
result, err := analytics.AWGetSalesHistory(r.Context(), h.awDB, daysBack)
if err != nil {
slog.Error("AWGetSalesHistory", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if result == nil {
result = []analytics.DailySalesPoint{}
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) AWForecast(w http.ResponseWriter, r *http.Request) {
horizonDays := queryInt(r, "horizon_days", h.defaultForecastDays)
result, err := analytics.AWGetSalesForecast(r.Context(), h.awDB, horizonDays)
if err != nil {
slog.Error("AWGetSalesForecast", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if result == nil {
result = []analytics.ForecastPoint{}
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) AWRepScores(w http.ResponseWriter, r *http.Request) {
topN := queryInt(r, "top_n", h.defaultTopN)
result, err := analytics.AWGetRepScores(r.Context(), h.awDB, topN)
if err != nil {
slog.Error("AWGetRepScores", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if result == nil {
result = []analytics.RepScore{}
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) AWProductDemand(w http.ResponseWriter, r *http.Request) {
topN := queryInt(r, "top_n", h.defaultTopN)
result, err := analytics.AWGetProductDemand(r.Context(), h.awDB, topN)
if err != nil {
slog.Error("AWGetProductDemand", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if result == nil {
result = []analytics.ProductDemand{}
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) AWAnomalies(w http.ResponseWriter, r *http.Request) {
result, err := analytics.AWRunAnomalyDetection(r.Context(), h.awDB)
if err != nil {
slog.Error("AWRunAnomalyDetection", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if result == nil {
result = []analytics.AnomalyPoint{}
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) AWDataQuality(w http.ResponseWriter, r *http.Request) {
result, err := analytics.AWRunDataQualityCheck(r.Context(), h.awDB)
if err != nil {
slog.Error("AWRunDataQualityCheck", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) WWIKPIs(w http.ResponseWriter, r *http.Request) {
result, err := analytics.WWIGetSalesKPIs(r.Context(), h.wwiDB)
if err != nil {
slog.Error("WWIGetSalesKPIs", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) WWIReorderRecommendations(w http.ResponseWriter, r *http.Request) {
result, err := analytics.WWIGetReorderRecommendations(r.Context(), h.wwiDB)
if err != nil {
slog.Error("WWIGetReorderRecommendations", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if result == nil {
result = []analytics.ReorderRecommendation{}
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) WWISupplierScores(w http.ResponseWriter, r *http.Request) {
topN := queryInt(r, "top_n", h.defaultTopN)
result, err := analytics.WWIGetSupplierScores(r.Context(), h.wwiDB, topN)
if err != nil {
slog.Error("WWIGetSupplierScores", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if result == nil {
result = []analytics.SupplierScore{}
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) WWIWhatIfScenario(w http.ResponseWriter, r *http.Request) {
var body struct {
StockItemKey int `json:"stock_item_key"`
DemandMultiplier float64 `json:"demand_multiplier"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.StockItemKey <= 0 {
writeError(w, http.StatusBadRequest, "stock_item_key must be > 0")
return
}
if body.DemandMultiplier <= 0 {
body.DemandMultiplier = 1.0
}
result, err := analytics.WWICreateWhatIfScenario(r.Context(), h.wwiDB, body.StockItemKey, body.DemandMultiplier)
if err != nil {
slog.Error("WWICreateWhatIfScenario", "err", err)
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) WWIDataQuality(w http.ResponseWriter, r *http.Request) {
result, err := analytics.WWIRunDataQualityCheck(r.Context(), h.wwiDB)
if err != nil {
slog.Error("WWIRunDataQualityCheck", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
// ---------------------------------------------------------------------------
// Export handlers
// ---------------------------------------------------------------------------
func (h *Handler) ExportAWSalesHistory(w http.ResponseWriter, r *http.Request) {
daysBack := queryInt(r, "days_back", h.defaultHistoryDays)
data, err := analytics.AWGetSalesHistory(r.Context(), h.awDB, daysBack)
if err != nil {
slog.Error("ExportAWSalesHistory", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cols := []export.Column{
{Key: "date", Label: "Date"},
{Key: "total_revenue", Label: "Total Revenue"},
{Key: "total_orders", Label: "Total Orders"},
{Key: "avg_order_value", Label: "Avg Order Value"},
}
b, err := export.ToXLSXBytes(r.Context(), "Sales History", cols, toMaps(data))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeXLSX(w, "aw_sales_history.xlsx", len(data), b)
}
func (h *Handler) ExportAWSalesForecast(w http.ResponseWriter, r *http.Request) {
horizonDays := queryInt(r, "horizon_days", h.defaultForecastDays)
data, err := analytics.AWGetSalesForecast(r.Context(), h.awDB, horizonDays)
if err != nil {
slog.Error("ExportAWSalesForecast", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cols := []export.Column{
{Key: "date", Label: "Date"},
{Key: "predicted_revenue", Label: "Predicted Revenue"},
{Key: "lower_bound", Label: "Lower Bound"},
{Key: "upper_bound", Label: "Upper Bound"},
}
b, err := export.ToXLSXBytes(r.Context(), "Sales Forecast", cols, toMaps(data))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeXLSX(w, "aw_sales_forecast.xlsx", len(data), b)
}
func (h *Handler) ExportAWRepScores(w http.ResponseWriter, r *http.Request) {
topN := queryInt(r, "top_n", h.defaultTopN)
data, err := analytics.AWGetRepScores(r.Context(), h.awDB, topN)
if err != nil {
slog.Error("ExportAWRepScores", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cols := []export.Column{
{Key: "rep_name", Label: "Sales Rep"},
{Key: "total_revenue", Label: "Total Revenue"},
{Key: "total_orders", Label: "Total Orders"},
{Key: "avg_order_value", Label: "Avg Order Value"},
{Key: "performance_score", Label: "Performance Score"},
}
b, err := export.ToXLSXBytes(r.Context(), "Rep Scores", cols, toMaps(data))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeXLSX(w, "aw_rep_scores.xlsx", len(data), b)
}
func (h *Handler) ExportAWProductDemand(w http.ResponseWriter, r *http.Request) {
topN := queryInt(r, "top_n", h.defaultTopN)
data, err := analytics.AWGetProductDemand(r.Context(), h.awDB, topN)
if err != nil {
slog.Error("ExportAWProductDemand", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cols := []export.Column{
{Key: "product_name", Label: "Product"},
{Key: "category", Label: "Category"},
{Key: "total_quantity", Label: "Total Quantity"},
{Key: "total_revenue", Label: "Total Revenue"},
{Key: "demand_score", Label: "Demand Score"},
}
b, err := export.ToXLSXBytes(r.Context(), "Product Demand", cols, toMaps(data))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeXLSX(w, "aw_product_demand.xlsx", len(data), b)
}
func (h *Handler) ExportWWIStockRecommendations(w http.ResponseWriter, r *http.Request) {
data, err := analytics.WWIGetReorderRecommendations(r.Context(), h.wwiDB)
if err != nil {
slog.Error("ExportWWIStockRecommendations", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cols := []export.Column{
{Key: "stock_item_name", Label: "Stock Item"},
{Key: "current_stock", Label: "Current Stock"},
{Key: "avg_daily_demand", Label: "Avg Daily Demand"},
{Key: "days_until_stockout", Label: "Days Until Stockout"},
{Key: "recommended_reorder_qty", Label: "Recommended Reorder Qty"},
{Key: "urgency", Label: "Urgency"},
}
b, err := export.ToXLSXBytes(r.Context(), "Stock Recommendations", cols, toMaps(data))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeXLSX(w, "wwi_stock_recommendations.xlsx", len(data), b)
}
func (h *Handler) ExportWWISupplierScores(w http.ResponseWriter, r *http.Request) {
topN := queryInt(r, "top_n", h.defaultTopN)
data, err := analytics.WWIGetSupplierScores(r.Context(), h.wwiDB, topN)
if err != nil {
slog.Error("ExportWWISupplierScores", "err", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cols := []export.Column{
{Key: "supplier_name", Label: "Supplier"},
{Key: "total_orders", Label: "Total Orders"},
{Key: "on_time_delivery_rate", Label: "On-Time Delivery Rate"},
{Key: "avg_lead_time_days", Label: "Avg Lead Time (Days)"},
{Key: "performance_score", Label: "Performance Score"},
}
b, err := export.ToXLSXBytes(r.Context(), "Supplier Scores", cols, toMaps(data))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeXLSX(w, "wwi_supplier_scores.xlsx", len(data), b)
}
// ---------------------------------------------------------------------------
// Scheduler trigger handlers
// ---------------------------------------------------------------------------
func (h *Handler) TriggerAWJob(w http.ResponseWriter, r *http.Request) {
jobName := r.PathValue("job_name")
if err := h.sched.TriggerAWJob(jobName); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "triggered", "job": jobName})
}
func (h *Handler) TriggerWWIJob(w http.ResponseWriter, r *http.Request) {
jobName := r.PathValue("job_name")
if err := h.sched.TriggerWWIJob(jobName); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "triggered", "job": jobName})
}