530 lines
17 KiB
Go
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, ¤tStock); 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
|
|
}
|