448 lines
15 KiB
Go
448 lines
15 KiB
Go
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})
|
|
}
|