Files
zavrsni-rad-otel-app/backend/analytics/internal/analytics/wwi.go
2026-05-11 10:58:46 +02:00

530 lines
17 KiB
Go

package analytics
import (
"context"
"database/sql"
"fmt"
"math"
"sort"
"time"
mssqldb "otel-bi-analytics/internal/db"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
var wwiTracer = otel.Tracer("otel-bi.analytics.wwi")
// ---------------------------------------------------------------------------
// SQL queries
// ---------------------------------------------------------------------------
var wwiDailySalesQueries = []string{
`SELECT
d.[Date] AS sale_date,
SUM(s.[Total Excluding Tax]) AS revenue,
SUM(s.[Total Excluding Tax] - s.[Profit]) AS cost,
SUM(CAST(s.[Quantity] AS FLOAT)) AS quantity,
COUNT_BIG(*) AS orders
FROM [Fact].[Sale] AS s
INNER JOIN [Dimension].[Date] AS d ON d.[Date Key] = s.[Delivery Date Key]
GROUP BY d.[Date]
ORDER BY d.[Date]`,
`SELECT
d.[Date] AS sale_date,
SUM(s.[Total Excluding Tax]) AS revenue,
SUM(s.[Total Excluding Tax] - s.[Profit]) AS cost,
SUM(CAST(s.[Quantity] AS FLOAT)) AS quantity,
COUNT_BIG(*) AS orders
FROM [Fact].[Sale] AS s
INNER JOIN [Dimension].[Date] AS d ON d.[Date Key] = s.[Invoice Date Key]
GROUP BY d.[Date]
ORDER BY d.[Date]`,
}
var wwiStockLevelsQueries = []string{
`SELECT
si.[Stock Item Key] AS stock_item_key,
si.[Stock Item] AS stock_item_name,
si.[Unit Price] AS unit_price,
si.[Lead Time Days] AS lead_time_days,
SUM(CAST(m.[Quantity] AS FLOAT)) AS current_stock
FROM [Dimension].[Stock Item] AS si
LEFT JOIN [Fact].[Movement] AS m ON m.[Stock Item Key] = si.[Stock Item Key]
WHERE si.[Stock Item Key] <> 0
GROUP BY si.[Stock Item Key], si.[Stock Item], si.[Unit Price], si.[Lead Time Days]`,
`SELECT
si.[Stock Item Key] AS stock_item_key,
si.[Stock Item] AS stock_item_name,
si.[Unit Price] AS unit_price,
si.[Lead Time Days] AS lead_time_days,
CAST(0 AS FLOAT) AS current_stock
FROM [Dimension].[Stock Item] AS si
WHERE si.[Stock Item Key] <> 0`,
}
var wwiDemandVelocityQueries = []string{
`SELECT
s.[Stock Item Key] AS stock_item_key,
SUM(CAST(s.[Quantity] AS FLOAT)) AS qty_sold_90d
FROM [Fact].[Sale] AS s
INNER JOIN [Dimension].[Date] AS d ON d.[Date Key] = s.[Delivery Date Key]
WHERE d.[Date] >= DATEADD(day, -90, GETDATE()) AND s.[Stock Item Key] <> 0
GROUP BY s.[Stock Item Key]`,
`SELECT
s.[Stock Item Key] AS stock_item_key,
SUM(CAST(s.[Quantity] AS FLOAT)) AS qty_sold_90d
FROM [Fact].[Sale] AS s
INNER JOIN [Dimension].[Date] AS d ON d.[Date Key] = s.[Invoice Date Key]
WHERE d.[Date] >= DATEADD(day, -90, GETDATE()) AND s.[Stock Item Key] <> 0
GROUP BY s.[Stock Item Key]`,
}
var wwiSupplierPerfQueries = []string{
`SELECT
sup.[Supplier Key] AS supplier_key,
sup.[Supplier] AS supplier_name,
sup.[Category] AS category,
COUNT_BIG(*) AS total_orders,
SUM(CAST(p.[Ordered Outers] AS FLOAT)) AS total_ordered_outers,
SUM(CAST(p.[Received Outers] AS FLOAT)) AS total_received_outers,
SUM(CASE WHEN p.[Is Order Finalized] = 1 THEN 1 ELSE 0 END) AS finalized_orders
FROM [Dimension].[Supplier] AS sup
INNER JOIN [Fact].[Purchase] AS p ON p.[Supplier Key] = sup.[Supplier Key]
WHERE sup.[Supplier Key] <> 0
GROUP BY sup.[Supplier Key], sup.[Supplier], sup.[Category]
ORDER BY total_orders DESC`,
`SELECT
sup.[Supplier Key] AS supplier_key,
sup.[Supplier] AS supplier_name,
sup.[Category] AS category,
COUNT_BIG(*) AS total_orders,
SUM(CAST(p.[Ordered Outers] AS FLOAT)) AS total_ordered_outers,
SUM(CAST(p.[Received Outers] AS FLOAT)) AS total_received_outers,
COUNT_BIG(*) AS finalized_orders
FROM [Dimension].[Supplier] AS sup
INNER JOIN [Fact].[Purchase] AS p ON p.[Supplier Key] = sup.[Supplier Key]
WHERE sup.[Supplier Key] <> 0
GROUP BY sup.[Supplier Key], sup.[Supplier], sup.[Category]
ORDER BY total_orders DESC`,
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ReorderRecommendation struct {
StockItemKey int `json:"stock_item_key"`
StockItemName string `json:"stock_item_name"`
UnitPrice float64 `json:"unit_price"`
CurrentStock float64 `json:"current_stock"`
AvgDailyDemand float64 `json:"avg_daily_demand"`
DaysUntilStockout *float64 `json:"days_until_stockout"`
RecommendedReorderQty int `json:"recommended_reorder_qty"`
Urgency string `json:"urgency"`
}
type SupplierScore struct {
Rank int `json:"rank"`
SupplierKey int `json:"supplier_key"`
SupplierName string `json:"supplier_name"`
Category string `json:"category"`
TotalOrders int `json:"total_orders"`
FillRatePct float64 `json:"fill_rate_pct"`
FinalizationRatePct float64 `json:"finalization_rate_pct"`
Score float64 `json:"score"`
}
type WhatIfResult struct {
StockItemKey int `json:"stock_item_key"`
StockItemName string `json:"stock_item_name"`
DemandMultiplier float64 `json:"demand_multiplier"`
CurrentStock float64 `json:"current_stock"`
BaseAvgDailyDemand float64 `json:"base_avg_daily_demand"`
AdjustedDailyDemand float64 `json:"adjusted_daily_demand"`
ProjectedDaysUntilStockout *float64 `json:"projected_days_until_stockout"`
ProjectedStockoutDate *string `json:"projected_stockout_date"`
RecommendedOrderQty int `json:"recommended_order_qty"`
EstimatedReorderCost float64 `json:"estimated_reorder_cost"`
}
// ---------------------------------------------------------------------------
// KPIs (same logic as AW)
// ---------------------------------------------------------------------------
func WWIGetSalesKPIs(ctx context.Context, db *sql.DB) (*SalesKPIs, error) {
ctx, span := wwiTracer.Start(ctx, "wwi.analytics.kpis")
defer span.End()
rows, err := mssqldb.QueryFirst(ctx, db, wwiDailySalesQueries)
if err != nil {
return nil, err
}
defer rows.Close()
cutoff := time.Now().UTC().AddDate(0, 0, -180)
var totalRevenue, totalCost, totalQuantity, totalOrders float64
var count int
for rows.Next() {
var date time.Time
var revenue, cost, quantity, orders sql.NullFloat64
if err := rows.Scan(&date, &revenue, &cost, &quantity, &orders); err != nil {
return nil, fmt.Errorf("scan wwi_daily_sales: %w", err)
}
if date.Before(cutoff) {
continue
}
totalRevenue += revenue.Float64
totalCost += cost.Float64
totalQuantity += quantity.Float64
totalOrders += orders.Float64
count++
}
if err := rows.Err(); err != nil {
return nil, err
}
if totalOrders < 1 {
totalOrders = 1
}
var marginPct float64
if totalRevenue > 0 {
marginPct = (totalRevenue - totalCost) / totalRevenue * 100
}
return &SalesKPIs{
TotalRevenue: round2(totalRevenue),
GrossMarginPct: round2(marginPct),
TotalQuantity: round2(totalQuantity),
AvgOrderValue: round2(totalRevenue / totalOrders),
RecordsInWindow: count,
}, nil
}
// ---------------------------------------------------------------------------
// Reorder recommendations
// ---------------------------------------------------------------------------
func urgency(days float64) string {
if days <= 7 {
return "HIGH"
}
if days <= 14 {
return "MEDIUM"
}
return "LOW"
}
func WWIGetReorderRecommendations(ctx context.Context, db *sql.DB) ([]ReorderRecommendation, error) {
ctx, span := wwiTracer.Start(ctx, "wwi.analytics.reorder_recommendations")
defer span.End()
// Fetch stock levels
stockRows, err := mssqldb.QueryFirst(ctx, db, wwiStockLevelsQueries)
if err != nil {
return nil, err
}
defer stockRows.Close()
type stockItem struct {
Key int
Name string
UnitPrice float64
LeadTimeDays float64
CurrentStock float64
}
byKey := make(map[int]*stockItem)
for stockRows.Next() {
var s stockItem
var price, lead, stock sql.NullFloat64
if err := stockRows.Scan(&s.Key, &s.Name, &price, &lead, &stock); err != nil {
return nil, fmt.Errorf("scan stock_levels: %w", err)
}
s.UnitPrice = price.Float64
s.LeadTimeDays = lead.Float64
if s.LeadTimeDays == 0 {
s.LeadTimeDays = 7
}
s.CurrentStock = stock.Float64
byKey[s.Key] = &s
}
if err := stockRows.Err(); err != nil {
return nil, err
}
// Fetch 90-day demand velocity
demandRows, err := mssqldb.QueryFirst(ctx, db, wwiDemandVelocityQueries)
if err != nil {
return nil, err
}
defer demandRows.Close()
demand := make(map[int]float64)
for demandRows.Next() {
var key int
var qty sql.NullFloat64
if err := demandRows.Scan(&key, &qty); err != nil {
return nil, fmt.Errorf("scan demand_velocity: %w", err)
}
demand[key] = qty.Float64
}
if err := demandRows.Err(); err != nil {
return nil, err
}
// Compute recommendations
var result []ReorderRecommendation
for _, s := range byKey {
avgDailyDemand := demand[s.Key] / 90.0
var daysUntilStockout float64
if avgDailyDemand > 0 {
daysUntilStockout = s.CurrentStock / avgDailyDemand
} else {
daysUntilStockout = math.Inf(1)
}
if daysUntilStockout > 30 && s.CurrentStock >= 0 {
continue
}
reorderQty := math.Max(math.Ceil(avgDailyDemand*s.LeadTimeDays*1.5), 1)
rec := ReorderRecommendation{
StockItemKey: s.Key,
StockItemName: s.Name,
UnitPrice: round2(s.UnitPrice),
CurrentStock: round2(s.CurrentStock),
AvgDailyDemand: round3(avgDailyDemand),
RecommendedReorderQty: int(reorderQty),
Urgency: urgency(daysUntilStockout),
}
if !math.IsInf(daysUntilStockout, 0) {
d := round2(daysUntilStockout)
rec.DaysUntilStockout = &d
}
result = append(result, rec)
}
sort.Slice(result, func(i, j int) bool {
di := math.Inf(1)
if result[i].DaysUntilStockout != nil {
di = *result[i].DaysUntilStockout
}
dj := math.Inf(1)
if result[j].DaysUntilStockout != nil {
dj = *result[j].DaysUntilStockout
}
return di < dj
})
span.SetAttributes(attribute.Int("item_count", len(result)))
return result, nil
}
// ---------------------------------------------------------------------------
// Supplier scores
// ---------------------------------------------------------------------------
func WWIGetSupplierScores(ctx context.Context, db *sql.DB, topN int) ([]SupplierScore, error) {
ctx, span := wwiTracer.Start(ctx, "wwi.analytics.supplier_scores",
trace.WithAttributes(attribute.Int("top_n", topN)))
defer span.End()
rows, err := mssqldb.QueryFirst(ctx, db, wwiSupplierPerfQueries)
if err != nil {
return nil, err
}
defer rows.Close()
type rawSupplier struct {
Key int
Name string
Category string
TotalOrders float64
OrderedOuters float64
ReceivedOuters float64
FinalizedOrders float64
}
var raws []rawSupplier
for rows.Next() {
var r rawSupplier
var orders, ordered, received, finalized sql.NullFloat64
if err := rows.Scan(&r.Key, &r.Name, &r.Category, &orders, &ordered, &received, &finalized); err != nil {
return nil, fmt.Errorf("scan supplier_performance: %w", err)
}
r.TotalOrders = orders.Float64
r.OrderedOuters = ordered.Float64
r.ReceivedOuters = received.Float64
r.FinalizedOrders = finalized.Float64
raws = append(raws, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
type scored struct {
raw rawSupplier
score float64
fill float64
final float64
}
scoreds := make([]scored, len(raws))
for i, r := range raws {
var fillRate, finalRate float64
if r.OrderedOuters > 0 {
fillRate = math.Min(r.ReceivedOuters/r.OrderedOuters*100, 100)
}
if r.TotalOrders > 0 {
finalRate = r.FinalizedOrders / r.TotalOrders * 100
}
s := 0.60*(fillRate/100) + 0.40*(finalRate/100)
scoreds[i] = scored{r, s, fillRate, finalRate}
}
sort.Slice(scoreds, func(i, j int) bool { return scoreds[i].score > scoreds[j].score })
if topN < len(scoreds) {
scoreds = scoreds[:topN]
}
result := make([]SupplierScore, len(scoreds))
for i, s := range scoreds {
result[i] = SupplierScore{
Rank: i + 1,
SupplierKey: s.raw.Key,
SupplierName: s.raw.Name,
Category: s.raw.Category,
TotalOrders: int(s.raw.TotalOrders),
FillRatePct: round2(s.fill),
FinalizationRatePct: round2(s.final),
Score: round2(s.score * 100),
}
}
return result, nil
}
// ---------------------------------------------------------------------------
// What-if scenario
// ---------------------------------------------------------------------------
func WWICreateWhatIfScenario(ctx context.Context, db *sql.DB, stockItemKey int, demandMultiplier float64) (*WhatIfResult, error) {
ctx, span := wwiTracer.Start(ctx, "wwi.analytics.whatif_scenario",
trace.WithAttributes(
attribute.Int("stock_item_key", stockItemKey),
attribute.Float64("demand_multiplier", demandMultiplier),
))
defer span.End()
const detailQ = `SELECT
si.[Stock Item Key], si.[Stock Item], si.[Unit Price], si.[Lead Time Days],
COALESCE(SUM(CAST(m.[Quantity] AS FLOAT)), 0) AS current_stock
FROM [Dimension].[Stock Item] AS si
LEFT JOIN [Fact].[Movement] AS m ON m.[Stock Item Key] = si.[Stock Item Key]
WHERE si.[Stock Item Key] = @stock_item_key
GROUP BY si.[Stock Item Key], si.[Stock Item], si.[Unit Price], si.[Lead Time Days]`
const demandQ = `SELECT
SUM(CAST(s.[Quantity] AS FLOAT)) / NULLIF(90.0, 0) AS avg_daily_demand
FROM [Fact].[Sale] AS s
INNER JOIN [Dimension].[Date] AS d ON d.[Date Key] = s.[Delivery Date Key]
WHERE s.[Stock Item Key] = @stock_item_key
AND d.[Date] >= DATEADD(day, -90, GETDATE())`
var itemKey int
var itemName string
var unitPrice, leadTime, currentStock sql.NullFloat64
row := db.QueryRowContext(ctx, detailQ, sql.Named("stock_item_key", stockItemKey))
if err := row.Scan(&itemKey, &itemName, &unitPrice, &leadTime, &currentStock); err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("stock item %d not found", stockItemKey)
}
return nil, fmt.Errorf("query stock item detail: %w", err)
}
lead := leadTime.Float64
if lead == 0 {
lead = 7
}
stock := currentStock.Float64
price := unitPrice.Float64
var baseDemand sql.NullFloat64
demRow := db.QueryRowContext(ctx, demandQ, sql.Named("stock_item_key", stockItemKey))
_ = demRow.Scan(&baseDemand)
adjustedDemand := baseDemand.Float64 * demandMultiplier
reorderQty := 0
var daysPtr *float64
var stockoutDatePtr *string
if adjustedDemand > 0 {
days := stock / adjustedDemand
d := round2(days)
daysPtr = &d
sd := time.Now().UTC().AddDate(0, 0, int(days)).Format("2006-01-02")
stockoutDatePtr = &sd
reorderQty = ceilInt(adjustedDemand * lead * 1.5)
}
return &WhatIfResult{
StockItemKey: stockItemKey,
StockItemName: itemName,
DemandMultiplier: demandMultiplier,
CurrentStock: round2(stock),
BaseAvgDailyDemand: round3(baseDemand.Float64),
AdjustedDailyDemand: round3(adjustedDemand),
ProjectedDaysUntilStockout: daysPtr,
ProjectedStockoutDate: stockoutDatePtr,
RecommendedOrderQty: reorderQty,
EstimatedReorderCost: round2(float64(reorderQty) * price),
}, nil
}
// ---------------------------------------------------------------------------
// Data quality
// ---------------------------------------------------------------------------
var wwiDQChecks = []struct {
name string
sql string
}{
{"fact_sale_rows", "SELECT COUNT_BIG(*) AS cnt FROM [Fact].[Sale]"},
{"active_suppliers", "SELECT COUNT_BIG(*) AS cnt FROM [Dimension].[Supplier] WHERE [Supplier Key] <> 0"},
{"stock_item_count", "SELECT COUNT_BIG(*) AS cnt FROM [Dimension].[Stock Item] WHERE [Stock Item Key] <> 0"},
{"stock_holdings", "SELECT COUNT(*) AS cnt FROM [Warehouse].[StockItemHoldings]"},
{"latest_sale_date", "SELECT MAX(d.[Date]) AS val FROM [Fact].[Sale] AS s INNER JOIN [Dimension].[Date] AS d ON d.[Date Key] = s.[Invoice Date Key]"},
}
func WWIRunDataQualityCheck(ctx context.Context, db *sql.DB) (*DataQualityResult, error) {
ctx, span := wwiTracer.Start(ctx, "wwi.analytics.data_quality")
defer span.End()
result := &DataQualityResult{
Checks: make(map[string]string),
FailedChecks: []string{},
}
for _, check := range wwiDQChecks {
row := db.QueryRowContext(ctx, check.sql)
var val sql.NullString
if err := row.Scan(&val); err != nil {
result.Checks[check.name] = fmt.Sprintf("ERROR: %v", err)
result.FailedChecks = append(result.FailedChecks, check.name)
continue
}
v := "NULL"
if val.Valid {
v = val.String
}
result.Checks[check.name] = v
if (v == "NULL" || v == "0") && check.name == "fact_sale_rows" {
result.FailedChecks = append(result.FailedChecks, check.name)
}
}
if len(result.FailedChecks) > 0 {
result.Status = "fail"
} else {
result.Status = "pass"
}
return result, nil
}