diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index a55ce14..ed77a71 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -445,12 +445,12 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_price_dim ( ` UPDATE mk_product_performance_price_dim SET - cost_price_usd = GREATEST(cost_price_usd, base_price_usd), - base_price_usd = LEAST(cost_price_usd, base_price_usd), + cost_price_usd = LEAST(cost_price_usd, base_price_usd), + base_price_usd = GREATEST(cost_price_usd, base_price_usd), updated_at = now() WHERE cost_price_usd > 0 AND base_price_usd > 0 - AND cost_price_usd < base_price_usd`, + AND cost_price_usd > base_price_usd`, ` DELETE FROM mk_product_performance_price_dim WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`, @@ -527,20 +527,20 @@ WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00D ` UPDATE mk_product_performance_kpi_daily SET - cost_price_usd = GREATEST(cost_price_usd, base_price_usd), - base_price_usd = LEAST(cost_price_usd, base_price_usd), - gross_profit_usd_90d = COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * GREATEST(cost_price_usd, base_price_usd)), - gross_profit_usd_180d = COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * GREATEST(cost_price_usd, base_price_usd)), - gross_margin_90d = CASE WHEN COALESCE(sales_usd_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * GREATEST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_90d,0) END, - gross_margin_180d = CASE WHEN COALESCE(sales_usd_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * GREATEST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_180d,0) END, - unit_profit_cost_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - GREATEST(cost_price_usd, base_price_usd) END, - unit_profit_cost_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - GREATEST(cost_price_usd, base_price_usd) END, - unit_profit_base_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - LEAST(cost_price_usd, base_price_usd) END, - unit_profit_base_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - LEAST(cost_price_usd, base_price_usd) END, + cost_price_usd = LEAST(cost_price_usd, base_price_usd), + base_price_usd = GREATEST(cost_price_usd, base_price_usd), + gross_profit_usd_90d = COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * LEAST(cost_price_usd, base_price_usd)), + gross_profit_usd_180d = COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * LEAST(cost_price_usd, base_price_usd)), + gross_margin_90d = CASE WHEN COALESCE(sales_usd_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * LEAST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_90d,0) END, + gross_margin_180d = CASE WHEN COALESCE(sales_usd_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * LEAST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_180d,0) END, + unit_profit_cost_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - LEAST(cost_price_usd, base_price_usd) END, + unit_profit_cost_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - LEAST(cost_price_usd, base_price_usd) END, + unit_profit_base_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - GREATEST(cost_price_usd, base_price_usd) END, + unit_profit_base_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - GREATEST(cost_price_usd, base_price_usd) END, updated_at = now() WHERE cost_price_usd > 0 AND base_price_usd > 0 - AND cost_price_usd < base_price_usd`, + AND cost_price_usd > base_price_usd`, ` UPDATE mk_product_performance_kpi_daily SET askili_yan = '', updated_at = now() @@ -4486,7 +4486,29 @@ FROM ( CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS sales_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS price_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS margin_index_90d, - CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(performance_score * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS performance_score, + CASE + WHEN SUM(CASE + WHEN COALESCE(sales_usd_90d,0) > 0 THEN sales_usd_90d + WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d + WHEN COALESCE(stock_qty,0) > 0 AND COALESCE(cost_price_usd,0) > 0 THEN stock_qty * cost_price_usd + WHEN COALESCE(stock_qty,0) > 0 THEN stock_qty + ELSE 1 + END) > 0 + THEN SUM(performance_score * CASE + WHEN COALESCE(sales_usd_90d,0) > 0 THEN sales_usd_90d + WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d + WHEN COALESCE(stock_qty,0) > 0 AND COALESCE(cost_price_usd,0) > 0 THEN stock_qty * cost_price_usd + WHEN COALESCE(stock_qty,0) > 0 THEN stock_qty + ELSE 1 + END) / NULLIF(SUM(CASE + WHEN COALESCE(sales_usd_90d,0) > 0 THEN sales_usd_90d + WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d + WHEN COALESCE(stock_qty,0) > 0 AND COALESCE(cost_price_usd,0) > 0 THEN stock_qty * cost_price_usd + WHEN COALESCE(stock_qty,0) > 0 THEN stock_qty + ELSE 1 + END),0) + ELSE 0 + END AS performance_score, MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket, COALESCE(MODE() WITHIN GROUP (ORDER BY NULLIF(recommendation,'')), '') AS recommendation, COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN idle_cost_usd ELSE 0 END),0) AS idle_cost_usd @@ -5167,7 +5189,7 @@ type productPerformanceGroupedSnapshotNode struct { Count int Children map[string]*productPerformanceGroupedSnapshotNode ChildOrder []string - StockSeen map[string]bool + StockMetricSeen map[string]map[string]bool IdleSeen map[string]bool Avg map[string]*productPerformanceGroupedSnapshotAvgState BucketCounts map[string]int @@ -5235,19 +5257,8 @@ func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) { continue } switch { - case key == "stock_qty": - variantKey := productPerformanceMapVariantKey(row) - if variantKey == "" { - n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value) - continue - } - if n.StockSeen == nil { - n.StockSeen = map[string]bool{} - } - if !n.StockSeen[variantKey] { - n.StockSeen[variantKey] = true - n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value) - } + case isProductPerformanceDistinctVariantStockMetric(key): + n.addDistinctVariantMetric(row, key, value) case key == "idle_cost_usd": variantKey := productPerformanceMapVariantKey(row) if variantKey == "" { @@ -5289,6 +5300,27 @@ func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) { n.addDistinctSpread(row) } +func (n *productPerformanceGroupedSnapshotNode) addDistinctVariantMetric(row map[string]any, key string, value any) { + variantKey := productPerformanceMapVariantKey(row) + if variantKey == "" { + n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value) + return + } + if n.StockMetricSeen == nil { + n.StockMetricSeen = map[string]map[string]bool{} + } + seen := n.StockMetricSeen[key] + if seen == nil { + seen = map[string]bool{} + n.StockMetricSeen[key] = seen + } + if seen[variantKey] { + return + } + seen[variantKey] = true + n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value) +} + func (n *productPerformanceGroupedSnapshotNode) addDistinctSpread(row map[string]any) { market := displayProductPerformanceMarketName(stringFromMap(row, "market_key")) if market != "" && market != "STOK" { @@ -5355,11 +5387,11 @@ func (n *productPerformanceGroupedSnapshotNode) snapshotRow() map[string]any { row[key] = state.Sum / state.Count } } + applyProductPerformanceDistinctSpread(row, n.Market90Seen, n.MarketTotalSeen, n.Customer90Seen, n.CustomerTotalSeen) deriveProductPerformanceGroupMetrics(row, n.Field) if bucket := n.dominantBucket(); bucket != "" { row["performance_bucket"] = bucket } - applyProductPerformanceDistinctSpread(row, n.Market90Seen, n.MarketTotalSeen, n.Customer90Seen, n.CustomerTotalSeen) clearProductPerformanceGroupDimensions(row, n.Field, n.Value) row["__group"] = true row["row_key"] = "group|" + n.Key @@ -5489,8 +5521,8 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m if isProductPerformanceMarginField(key) { continue } - if key == "stock_qty" { - out[key] = distinctProductPerformanceVariantStockQty(rows) + if isProductPerformanceDistinctVariantStockMetric(key) { + out[key] = distinctProductPerformanceVariantNumber(rows, key) continue } if key == "idle_cost_usd" { @@ -5517,9 +5549,9 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m } } } + applyProductPerformanceDistinctSpread(out, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen) deriveProductPerformanceGroupMetrics(out, groupField) out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket") - applyProductPerformanceDistinctSpread(out, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen) return out } @@ -5613,7 +5645,11 @@ func isProductPerformanceMarginField(field string) bool { return strings.HasPrefix(field, "gross_margin") } -func distinctProductPerformanceVariantStockQty(rows []map[string]any) float64 { +func isProductPerformanceDistinctVariantStockMetric(field string) bool { + return field == "stock_qty" || strings.HasPrefix(field, "avg_stock_") +} + +func distinctProductPerformanceVariantNumber(rows []map[string]any, field string) float64 { seen := map[string]bool{} total := 0.0 hasKey := false @@ -5627,13 +5663,13 @@ func distinctProductPerformanceVariantStockQty(rows []map[string]any) float64 { continue } seen[key] = true - total += floatFromMap(row, "stock_qty") + total += floatFromMap(row, field) } if hasKey { return total } for _, row := range rows { - total += floatFromMap(row, "stock_qty") + total += floatFromMap(row, field) } return total } @@ -5674,36 +5710,57 @@ func productPerformanceMapVariantKey(row map[string]any) string { } func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) { + normalizeProductPerformanceCostFields(out) for _, suffix := range []string{"90d", "180d", "365d", "total"} { sales := floatFromMap(out, "sales_usd_"+suffix) qty := floatFromMap(out, "sales_qty_"+suffix) stockQty := floatFromMap(out, "stock_qty") - if _, exists := out["stock_turnover_"+suffix]; !exists && stockQty > 0 { - out["stock_turnover_"+suffix] = qty / stockQty + turnoverBase := floatFromMap(out, "avg_stock_"+suffix) + if turnoverBase <= 0 { + turnoverBase = stockQty + } + if turnoverBase > 0 { + out["stock_turnover_"+suffix] = qty / turnoverBase + } else { + out["stock_turnover_"+suffix] = 0 } if qty > 0 { out["avg_price_usd_"+suffix] = sales / qty } basePrice, hasBasePrice := productPerformanceUnitCostForSuffix(out, "base_price_usd", suffix) costPrice, hasCostPrice := productPerformanceUnitCostForSuffix(out, "cost_price_usd", suffix) + if hasBasePrice { + out["base_price_usd_"+suffix] = basePrice + } + if hasCostPrice { + out["cost_price_usd_"+suffix] = costPrice + } if qty > 0 && hasBasePrice { - out["unit_profit_base_"+suffix] = (sales / qty) - basePrice + avgPrice := sales / qty + out["unit_profit_base_"+suffix] = avgPrice - basePrice out["gross_profit_base_usd_"+suffix] = sales - (qty * basePrice) + } else { + out["unit_profit_base_"+suffix] = 0 + out["gross_profit_base_usd_"+suffix] = 0 } if qty > 0 && hasCostPrice { - out["unit_profit_cost_"+suffix] = (sales / qty) - costPrice + avgPrice := sales / qty + out["unit_profit_cost_"+suffix] = avgPrice - costPrice out["gross_profit_cost_usd_"+suffix] = sales - (qty * costPrice) + out["gross_profit_usd_"+suffix] = out["gross_profit_cost_usd_"+suffix] + } else { + out["unit_profit_cost_"+suffix] = 0 + out["gross_profit_cost_usd_"+suffix] = 0 + out["gross_profit_usd_"+suffix] = 0 } if sales > 0 { - if _, ok := out["gross_profit_base_usd_"+suffix]; ok { - out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales - } - if _, ok := out["gross_profit_cost_usd_"+suffix]; ok { - out["gross_margin_cost_"+suffix] = floatFromMap(out, "gross_profit_cost_usd_"+suffix) / sales - } - if _, ok := out["gross_profit_usd_"+suffix]; ok { - out["gross_margin_"+suffix] = floatFromMap(out, "gross_profit_usd_"+suffix) / sales - } + out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales + out["gross_margin_cost_"+suffix] = floatFromMap(out, "gross_profit_cost_usd_"+suffix) / sales + out["gross_margin_"+suffix] = floatFromMap(out, "gross_profit_usd_"+suffix) / sales + } else { + out["gross_margin_base_"+suffix] = 0 + out["gross_margin_cost_"+suffix] = 0 + out["gross_margin_"+suffix] = 0 } } orderUSD := floatFromMap(out, "order_usd") @@ -5743,7 +5800,11 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) out["performance_score_total"] = productPerformanceSalesPeriodScore(out, "total") } if _, ok := out["performance_score"]; !ok { - out["performance_score"] = productPerformanceGroupScore(out, groupField) + if score, exists := productPerformanceOptionalFloat(out, "performance_score_90d"); exists { + out["performance_score"] = score + } else { + out["performance_score"] = productPerformanceGroupScore(out, groupField) + } } if floatFromMap(out, "order_qty") > 0 || floatFromMap(out, "order_usd") > 0 { score := productPerformanceOrderGroupScore(out) @@ -5751,16 +5812,53 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) out["performance_score_180d"] = score out["performance_score_365d"] = score out["performance_score_total"] = score + out["performance_score"] = score } } +func normalizeProductPerformanceCostFields(row map[string]any) { + normalize := func(costField, baseField string) { + cost, hasCost := productPerformanceOptionalFloat(row, costField) + base, hasBase := productPerformanceOptionalFloat(row, baseField) + if !hasCost && !hasBase { + return + } + cost, base = normalizeProductPerformanceCostPair(cost, base) + if hasCost || cost > 0 { + row[costField] = cost + } + if hasBase || base > 0 { + row[baseField] = base + } + } + normalize("cost_price_usd", "base_price_usd") + for _, suffix := range []string{"90d", "180d", "365d", "total"} { + normalize("cost_price_usd_"+suffix, "base_price_usd_"+suffix) + } +} + +func productPerformanceOptionalFloat(row map[string]any, field string) (float64, bool) { + value, ok := row[field] + if !ok { + return 0, false + } + return floatFromAny(value), true +} + func productPerformanceUnitCostForSuffix(row map[string]any, baseField, suffix string) (float64, bool) { + hasSuffix := false if value, ok := row[baseField+"_"+suffix]; ok { - return floatFromAny(value), true + hasSuffix = true + if n := floatFromAny(value); n > 0 { + return n, true + } } if value, ok := row[baseField]; ok { return floatFromAny(value), true } + if hasSuffix { + return 0, true + } return 0, false } @@ -6099,6 +6197,7 @@ func shouldAverageProductPerformanceField(field string) bool { strings.HasPrefix(field, "expected_margin") || strings.HasPrefix(field, "sales_index") || strings.HasPrefix(field, "performance_score") || + strings.HasPrefix(field, "customer_score") || strings.HasPrefix(field, "stock_days") || strings.HasPrefix(field, "stock_turnover") } @@ -6136,6 +6235,9 @@ func productPerformanceUsesCostWeight(field string) bool { } func productPerformanceMetricWeight(row map[string]any, field string) float64 { + if productPerformanceUsesScoreWeight(field) { + return productPerformanceScoreWeight(row, productPerformanceScoreSuffix(field)) + } weightField := productPerformanceMetricWeightField(field) if weightField == "__product_cost_weight" { return productPerformanceCostWeight(row) @@ -6143,6 +6245,44 @@ func productPerformanceMetricWeight(row map[string]any, field string) float64 { return floatFromMap(row, weightField) } +func productPerformanceUsesScoreWeight(field string) bool { + return strings.HasPrefix(field, "performance_score") || + strings.HasPrefix(field, "customer_score") +} + +func productPerformanceScoreSuffix(field string) string { + switch { + case strings.Contains(field, "_180d"): + return "180d" + case strings.Contains(field, "_365d"): + return "365d" + case strings.Contains(field, "_total"): + return "total" + default: + return "90d" + } +} + +func productPerformanceScoreWeight(row map[string]any, suffix string) float64 { + if weight := floatFromMap(row, "sales_usd_"+suffix); weight > 0 { + return weight + } + if weight := floatFromMap(row, "sales_qty_"+suffix); weight > 0 { + return weight + } + stockQty := floatFromMap(row, "stock_qty") + if stockQty <= 0 { + return 1 + } + if cost := floatFromMap(row, "cost_price_usd_"+suffix); cost > 0 { + return stockQty * cost + } + if cost := floatFromMap(row, "cost_price_usd"); cost > 0 { + return stockQty * cost + } + return stockQty +} + func productPerformanceCostWeight(row map[string]any) float64 { if weight := floatFromMap(row, "sales_qty_total"); weight > 0 { return weight @@ -6156,6 +6296,9 @@ func productPerformanceCostWeight(row map[string]any) float64 { } func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qtyField string) float64 { + if productPerformanceUsesScoreWeight(valueField) { + return weightedAverageProductPerformanceScoreRows(rows, valueField, productPerformanceScoreSuffix(valueField)) + } var weighted, qty float64 for _, row := range rows { value := floatFromMap(row, valueField) @@ -6177,6 +6320,30 @@ func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qt return 0 } +func weightedAverageProductPerformanceScoreRows(rows []map[string]any, valueField, suffix string) float64 { + var weighted, weight, sum, count float64 + for _, row := range rows { + value, ok := productPerformanceOptionalFloat(row, valueField) + if !ok { + continue + } + rowWeight := productPerformanceScoreWeight(row, suffix) + if rowWeight > 0 { + weighted += value * rowWeight + weight += rowWeight + } + sum += value + count++ + } + if weight > 0 { + return weighted / weight + } + if count > 0 { + return sum / count + } + return 0 +} + func averageProductPerformanceDistinctVariantField(rows []map[string]any, valueField string) float64 { seen := map[string]bool{} sum := 0.0 @@ -6642,7 +6809,7 @@ func productPerformanceVariantKey(productCode, colorCode, yakaKodu string) strin } func normalizeProductPerformanceCostPair(costPriceUSD, basePriceUSD float64) (float64, float64) { - if costPriceUSD > 0 && basePriceUSD > 0 && costPriceUSD < basePriceUSD { + if costPriceUSD > 0 && basePriceUSD > 0 && costPriceUSD > basePriceUSD { return basePriceUSD, costPriceUSD } return costPriceUSD, basePriceUSD diff --git a/svc/queries/product_performance_sql.go b/svc/queries/product_performance_sql.go index 718edf4..4c44512 100644 --- a/svc/queries/product_performance_sql.go +++ b/svc/queries/product_performance_sql.go @@ -549,11 +549,11 @@ PriceDim AS ( COALESCE(urun_ana_grubu, '') AS urun_ana_grubu, COALESCE(urun_alt_grubu, '') AS urun_alt_grubu, CASE - WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd < base_price_usd THEN base_price_usd + WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd > base_price_usd THEN base_price_usd ELSE cost_price_usd END AS cost_price_usd, CASE - WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd < base_price_usd THEN cost_price_usd + WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd > base_price_usd THEN cost_price_usd ELSE base_price_usd END AS base_price_usd, base_price_try diff --git a/svc/routes/product_performance_excel.go b/svc/routes/product_performance_excel.go index 4917400..3fd9805 100644 --- a/svc/routes/product_performance_excel.go +++ b/svc/routes/product_performance_excel.go @@ -233,6 +233,7 @@ func (v *productPerformanceExcelVariant) fillDimensions(kpiDate, productCode, co v.UrunAltGrubu = productPerformanceExcelFirstNonEmpty(v.UrunAltGrubu, urunAltGrubu) v.BasePriceUSD = productPerformanceExcelFirstNonZero(v.BasePriceUSD, basePrice) v.CostPriceUSD = productPerformanceExcelFirstNonZero(v.CostPriceUSD, costPrice) + v.CostPriceUSD, v.BasePriceUSD = productPerformanceExcelNormalizeCostPair(v.CostPriceUSD, v.BasePriceUSD) if stockQty > v.StockQty { v.StockQty = stockQty } @@ -402,11 +403,11 @@ func productPerformanceExcelPeriodColumns(label, suffix string, values func(*pro _, usd, _, _ := values(v) return usd }), - numberExcelColumn(label+" Ciro/Adet USD", "number", func(v *productPerformanceExcelVariant) any { + numberExcelColumn(label+" Ort. Satış Fiyatı USD", "number", func(v *productPerformanceExcelVariant) any { qty, usd, _, _ := values(v) return productPerformanceExcelAvgPrice(usd, qty) }), - numberExcelColumn(label+" Satış/Stok Oranı", "number", func(v *productPerformanceExcelVariant) any { + numberExcelColumn(label+" Stok Devir Hızı", "number", func(v *productPerformanceExcelVariant) any { qty, _, _, _ := values(v) return productPerformanceExcelStockTurnover(qty, v.StockQty) }), @@ -866,6 +867,13 @@ func productPerformanceExcelFirstNonZero(values ...float64) float64 { return 0 } +func productPerformanceExcelNormalizeCostPair(costPriceUSD, basePriceUSD float64) (float64, float64) { + if costPriceUSD > 0 && basePriceUSD > 0 && costPriceUSD > basePriceUSD { + return basePriceUSD, costPriceUSD + } + return costPriceUSD, basePriceUSD +} + func productPerformanceExcelAvgPrice(salesUSD, qty float64) float64 { if qty <= 0 { return 0 diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 68b02f4..9169e8f 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -1784,7 +1784,7 @@ const detailSalesColumns = [ { name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true }, { name: 'sales_qty', label: 'Adet', field: row => formatNumber(row.sales_qty, 2), align: 'right', sortable: true }, { name: 'sales_usd', label: 'USD', field: row => formatMoney(row.sales_usd, 'USD'), align: 'right', sortable: true }, - { name: 'avg_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd, 'USD'), align: 'right', sortable: true } + { name: 'avg_price_usd', label: 'Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd, 'USD'), align: 'right', sortable: true } ] const columns = [ @@ -1816,14 +1816,14 @@ const columns = [ { name: 'stock_days_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 1), align: 'right', sortable: true }, { name: 'stock_days_365d', label: '360G Stok Gün', field: row => formatNumber(row.stock_days_365d, 1), align: 'right', sortable: true }, { name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true }, - { name: 'stock_turnover_90d', label: '90G Satış/Stok', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true }, - { name: 'stock_turnover_180d', label: '180G Satış/Stok', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true }, - { name: 'stock_turnover_365d', label: '360G Satış/Stok', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true }, - { name: 'stock_turnover_total', label: 'Genel Satış/Stok', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true }, - { name: 'avg_price_usd_90d', label: '90G Ciro/Adet USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' }, - { name: 'avg_price_usd_180d', label: '180G Ciro/Adet USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' }, - { name: 'avg_price_usd_365d', label: '360G Ciro/Adet USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' }, - { name: 'avg_price_usd_total', label: 'Genel Ciro/Adet USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right' }, + { name: 'stock_turnover_90d', label: '90G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true }, + { name: 'stock_turnover_180d', label: '180G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true }, + { name: 'stock_turnover_365d', label: '360G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true }, + { name: 'stock_turnover_total', label: 'Genel Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true }, + { name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' }, + { name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' }, + { name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' }, + { name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right' }, { name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_base_90d', label: '90G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_90d, 'USD'), align: 'right', sortable: true }, @@ -2045,7 +2045,7 @@ const generalColumns = [ { name: 'sales_usd_total', label: 'Toplam Ciro USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'avg_daily_sales_total', label: 'Günlük Ort.', field: row => formatNumber(row.avg_daily_sales_total, 3), align: 'right', sortable: true }, { name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true }, - { name: 'avg_price_usd_total', label: 'Genel Ciro/Adet USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true }, + { name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_base_total', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true }, @@ -2108,7 +2108,7 @@ const orderGroupColumns = [ { name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true }, { name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true }, { name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true }, - { name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true }, + { name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true }, { name: 'base_cost_usd', label: 'Taban Maliyet USD', field: row => formatMoney(row.base_cost_usd, 'USD'), align: 'right', sortable: true }, { name: 'cost_amount_usd', label: 'Çıplak Maliyet USD', field: row => formatMoney(row.cost_amount_usd, 'USD'), align: 'right', sortable: true }, { name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true }, @@ -2142,7 +2142,7 @@ const orderProductCustomerColumns = [ { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true }, { name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true }, { name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true }, - { name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true }, + { name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true }, { name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }, { name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true }, { name: 'base_price_usd', label: 'Taban USD', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true }, @@ -2176,7 +2176,7 @@ const orderMarketDetailColumns = [ { name: 'due_date', label: 'Termin', field: 'due_date', align: 'left', sortable: true }, { name: 'order_qty', label: 'Adet', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true }, { name: 'order_usd', label: 'USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true }, - { name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true }, + { name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true }, { name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }, { name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true }, { name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true }, @@ -2205,7 +2205,7 @@ const idleColumns = [ { name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true }, { name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, { name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true }, - { name: 'stock_turnover_90d', label: '90G Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true }, + { name: 'stock_turnover_90d', label: '90G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true }, { name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' }, { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } ] @@ -2236,7 +2236,7 @@ const countryColumns = [ { name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true }, { name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: row => formatMoney(row.sales_usd_90d, 'USD'), align: 'right', sortable: true }, - { name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right', sortable: true }, + { name: 'avg_price_usd_90d', label: 'Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right', sortable: true }, { name: 'customer_count_90d', label: 'Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true }, { name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true }, { name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true }, @@ -2252,7 +2252,7 @@ const customerColumns = [ { name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true }, { name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true }, - { name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true }, + { name: 'avg_price_usd_90d', label: 'Ort. Satış Fiyatı USD', field: 'avg_price_usd_90d', align: 'right', sortable: true }, { name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 2), align: 'right', sortable: true }, { name: 'base_price_usd_90d', label: '90G Taban', field: row => formatMoney(row.base_price_usd_90d, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_90d', label: '90G Çıplak', field: row => formatMoney(row.cost_price_usd_90d, 'USD'), align: 'right', sortable: true }, @@ -2287,14 +2287,14 @@ const salesBreakdownColumns = [ { name: 'invoice_count_90d', label: '90G Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true }, { name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true }, - { name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true }, - { name: 'stock_turnover_90d', label: '90G Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true }, + { name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: 'avg_price_usd_90d', align: 'right', sortable: true }, + { name: 'stock_turnover_90d', label: '90G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true }, { name: 'customer_count_180d', label: '180G Müşteri', field: row => formatNumber(row.customer_count_180d, 0), align: 'right', sortable: true }, { name: 'invoice_count_180d', label: '180G Fatura', field: row => formatNumber(row.invoice_count_180d, 0), align: 'right', sortable: true }, { name: 'sales_qty_180d', label: '180G Adet', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true }, { name: 'sales_usd_180d', label: '180G USD', field: row => formatMoney(row.sales_usd_180d, 'USD'), align: 'right', sortable: true }, - { name: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true }, - { name: 'stock_turnover_180d', label: '180G Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true }, + { name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true }, + { name: 'stock_turnover_180d', label: '180G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true }, { name: 'base_price_usd_180d', label: '180G Taban', field: row => formatMoney(row.base_price_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_180d', label: '180G Çıplak', field: row => formatMoney(row.cost_price_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_180d', label: '180G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_180d, 'USD'), align: 'right', sortable: true }, @@ -2304,8 +2304,8 @@ const salesBreakdownColumns = [ { name: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 2), align: 'right', sortable: true }, { name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true }, { name: 'sales_usd_365d', label: '360G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true }, - { name: 'avg_price_usd_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true }, - { name: 'stock_turnover_365d', label: '360G Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true }, + { name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true }, + { name: 'stock_turnover_365d', label: '360G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true }, { name: 'base_price_usd_365d', label: '360G Taban', field: row => formatMoney(row.base_price_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_365d', label: '360G Çıplak', field: row => formatMoney(row.cost_price_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_365d', label: '360G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_365d, 'USD'), align: 'right', sortable: true }, @@ -2317,8 +2317,8 @@ const salesBreakdownColumns = [ { name: 'invoice_count_total', label: 'Genel Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true }, { name: 'sales_qty_total', label: 'Genel Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true }, { name: 'sales_usd_total', label: 'Genel USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true }, - { name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true }, - { name: 'stock_turnover_total', label: 'Genel Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true }, + { name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true }, + { name: 'stock_turnover_total', label: 'Genel Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true }, { name: 'base_price_usd_total', label: 'Genel Taban', field: row => formatMoney(row.base_price_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_total', label: 'Genel Çıplak', field: row => formatMoney(row.cost_price_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true }, @@ -2359,11 +2359,11 @@ const metricLabelOverrides = { sales_usd_180d: '180G Toplam Ciro USD', sales_usd_365d: '360G Toplam Ciro USD', sales_usd_total: 'Genel Toplam Ciro USD', - avg_price_usd: 'Ciro/Adet USD', - avg_price_usd_90d: '90G Ciro/Adet USD', - avg_price_usd_180d: '180G Ciro/Adet USD', - avg_price_usd_365d: '360G Ciro/Adet USD', - avg_price_usd_total: 'Genel Ciro/Adet USD', + avg_price_usd: 'Ort. Satış Fiyatı USD', + avg_price_usd_90d: '90G Ort. Satış Fiyatı USD', + avg_price_usd_180d: '180G Ort. Satış Fiyatı USD', + avg_price_usd_365d: '360G Ort. Satış Fiyatı USD', + avg_price_usd_total: 'Genel Ort. Satış Fiyatı USD', base_price_usd: 'Taban Maliyet USD', cost_price_usd: 'Çıplak Maliyet USD', base_price_usd_90d: '90G Taban Maliyet USD', @@ -2412,10 +2412,10 @@ const metricLabelOverrides = { customer_count_90d: '90G Tekil Müşteri', market_count_total: 'Genel Tekil Piyasa', customer_count_total: 'Genel Tekil Müşteri', - stock_turnover_90d: '90G Satış/Stok', - stock_turnover_180d: '180G Satış/Stok', - stock_turnover_365d: '360G Satış/Stok', - stock_turnover_total: 'Genel Satış/Stok', + stock_turnover_90d: '90G Stok Devir Hızı', + stock_turnover_180d: '180G Stok Devir Hızı', + stock_turnover_365d: '360G Stok Devir Hızı', + stock_turnover_total: 'Genel Stok Devir Hızı', sales_index_90d: '90G Endeks', sales_index_total: 'Genel Endeks', stock_days_90d: '90G Stok Gün', @@ -2645,7 +2645,7 @@ function performanceCardsForRow (row) { { key: 'sales180', label: '180G Satış', value: formatNumber(source.sales_qty_180d, 0) }, { key: 'markets', label: '90G Piyasa', value: formatNumber(source.market_count_90d, 0) }, { key: 'customers', label: '90G Müşteri', value: formatNumber(source.customer_count_90d, 0) }, - { key: 'avg', label: 'Ort. USD Satış', value: formatMoney(source.avg_price_usd_90d, 'USD') }, + { key: 'avg', label: 'Ort. Satış Fiyatı', value: formatMoney(source.avg_price_usd_90d, 'USD') }, { key: 'base', label: 'Taban Maliyet', value: formatMoney(source.base_price_usd, 'USD') }, { key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(source.cost_price_usd, 'USD') }, { key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(source.unit_profit_base_90d, 'USD') }, @@ -3470,7 +3470,7 @@ function shouldSumField (field) { } function shouldAverageField (field) { - return /^(avg_|unit_|base_price|cost_price|gross_margin|expected_margin|sales_index|performance_score|stock_days|stock_turnover)/i.test(field) || + return /^(avg_|unit_|base_price|cost_price|gross_margin|expected_margin|sales_index|performance_score|customer_score|stock_days|stock_turnover)/i.test(field) || /(_price_usd|_margin|_index|_days)$/i.test(field) } @@ -3484,21 +3484,35 @@ function weightFieldForMetric (field) { } function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { + Object.assign(out, normalizeProductCostFields(out)) for (const suffix of ['90d', '180d', '365d', 'total']) { const sales = Number(out[`sales_usd_${suffix}`] || 0) const qty = Number(out[`sales_qty_${suffix}`] || 0) + const stockQty = Number(out.stock_qty || 0) + const avgStock = Number(out[`avg_stock_${suffix}`] || 0) + const turnoverBase = avgStock > 0 ? avgStock : stockQty + out[`stock_turnover_${suffix}`] = turnoverBase > 0 ? qty / turnoverBase : 0 if (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty - if (sales > 0) { - if (out[`gross_profit_base_usd_${suffix}`] !== undefined) { - out[`gross_margin_base_${suffix}`] = Number(out[`gross_profit_base_usd_${suffix}`] || 0) / sales - } - if (out[`gross_profit_cost_usd_${suffix}`] !== undefined) { - out[`gross_margin_cost_${suffix}`] = Number(out[`gross_profit_cost_usd_${suffix}`] || 0) / sales - } - if (out[`gross_profit_usd_${suffix}`] !== undefined) { - out[`gross_margin_${suffix}`] = Number(out[`gross_profit_usd_${suffix}`] || 0) / sales - } + const { costPrice, basePrice } = periodCostPair(out, suffix) + out[`base_price_usd_${suffix}`] = basePrice + out[`cost_price_usd_${suffix}`] = costPrice + if (qty > 0) { + const avgPrice = sales / qty + out[`unit_profit_base_${suffix}`] = avgPrice - basePrice + out[`unit_profit_cost_${suffix}`] = avgPrice - costPrice + out[`gross_profit_base_usd_${suffix}`] = sales - qty * basePrice + out[`gross_profit_cost_usd_${suffix}`] = sales - qty * costPrice + out[`gross_profit_usd_${suffix}`] = out[`gross_profit_cost_usd_${suffix}`] + } else { + out[`unit_profit_base_${suffix}`] = 0 + out[`unit_profit_cost_${suffix}`] = 0 + out[`gross_profit_base_usd_${suffix}`] = 0 + out[`gross_profit_cost_usd_${suffix}`] = 0 + out[`gross_profit_usd_${suffix}`] = 0 } + out[`gross_margin_base_${suffix}`] = sales > 0 ? Number(out[`gross_profit_base_usd_${suffix}`] || 0) / sales : 0 + out[`gross_margin_cost_${suffix}`] = sales > 0 ? Number(out[`gross_profit_cost_usd_${suffix}`] || 0) / sales : 0 + out[`gross_margin_${suffix}`] = sales > 0 ? Number(out[`gross_profit_usd_${suffix}`] || 0) / sales : 0 } const orderUsd = Number(out.order_usd || 0) @@ -3524,7 +3538,11 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_180d')) out.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(out, '180d')) if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_365d')) out.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(out, '365d')) if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_total')) out.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(out, 'total')) - if (!Object.prototype.hasOwnProperty.call(out, 'performance_score')) out.performance_score = groupPerformanceScore(out, groupField) + if (!Object.prototype.hasOwnProperty.call(out, 'performance_score')) { + out.performance_score = Object.prototype.hasOwnProperty.call(out, 'performance_score_90d') + ? Number(out.performance_score_90d || 0) + : groupPerformanceScore(out, groupField) + } } function groupPerformanceScore (row, groupField = '') { @@ -3837,6 +3855,9 @@ function weightedAverage (sourceRows, amountField, qtyField) { } function weightedAverageOrAverage (sourceRows, valueField, qtyField) { + if (/^(performance_score|customer_score)/i.test(valueField)) { + return weightedAverageScore(sourceRows, valueField, scoreSuffixForMetric(valueField)) + } const weightedRows = sourceRows .map(row => ({ value: Number(row[valueField] || 0), @@ -3850,6 +3871,39 @@ function weightedAverageOrAverage (sourceRows, valueField, qtyField) { return 0 } +function scoreSuffixForMetric (field) { + if (field.includes('_180d')) return '180d' + if (field.includes('_365d')) return '365d' + if (field.includes('_total')) return 'total' + return '90d' +} + +function scoreWeight (row, suffix) { + const salesUsd = Number(row?.[`sales_usd_${suffix}`] || 0) + if (salesUsd > 0) return salesUsd + const salesQty = Number(row?.[`sales_qty_${suffix}`] || 0) + if (salesQty > 0) return salesQty + const stockQty = Number(row?.stock_qty || 0) + if (stockQty <= 0) return 1 + const costPrice = Number(row?.[`cost_price_usd_${suffix}`] || row?.cost_price_usd || 0) + return costPrice > 0 ? stockQty * costPrice : stockQty +} + +function weightedAverageScore (sourceRows, valueField, suffix) { + const rows = sourceRows + .filter(row => Object.prototype.hasOwnProperty.call(row || {}, valueField)) + .map(row => ({ + value: Number(row[valueField] || 0), + weight: scoreWeight(row, suffix) + })) + .filter(row => Number.isFinite(row.value) && Number.isFinite(row.weight)) + const weight = rows.reduce((sum, row) => sum + Math.max(0, row.weight), 0) + if (weight > 0) { + return rows.reduce((sum, row) => sum + row.value * Math.max(0, row.weight), 0) / weight + } + return rows.length ? rows.reduce((sum, row) => sum + row.value, 0) / rows.length : 0 +} + function weightedAverageProductCost (sourceRows, valueField) { const salesRows = sourceRows .map(row => ({ @@ -4068,53 +4122,93 @@ function formatGroupCell (row, colOrName) { } function withProductMargins (row) { - const salesQtyTotal = Number(row?.sales_qty_total || 0) - const salesUSDTotal = Number(row?.sales_usd_total || 0) - const basePrice = Number(row?.base_price_usd || 0) - const costPrice = Number(row?.cost_price_usd || 0) - const grossProfitBaseTotal = salesQtyTotal > 0 ? salesUSDTotal - (salesQtyTotal * basePrice) : 0 - const grossProfitCostTotal = salesQtyTotal > 0 ? salesUSDTotal - (salesQtyTotal * costPrice) : 0 - return { - ...row, - avg_price_usd_365d: Number(row?.sales_qty_365d || 0) > 0 ? Number(row?.sales_usd_365d || 0) / Number(row?.sales_qty_365d || 0) : 0, - avg_price_usd_total: salesQtyTotal > 0 ? salesUSDTotal / salesQtyTotal : Number(row?.avg_price_usd_total || 0), - stock_turnover_90d: existingOrStockTurnover(row?.stock_turnover_90d, row?.sales_qty_90d, row?.stock_qty), - stock_turnover_180d: existingOrStockTurnover(row?.stock_turnover_180d, row?.sales_qty_180d, row?.stock_qty), - stock_turnover_365d: existingOrStockTurnover(row?.stock_turnover_365d, row?.sales_qty_365d, row?.stock_qty), - stock_turnover_total: existingOrStockTurnover(row?.stock_turnover_total, row?.sales_qty_total, row?.stock_qty), - gross_profit_base_usd_total: grossProfitBaseTotal, - gross_profit_cost_usd_total: grossProfitCostTotal, - gross_profit_usd_total: Number(row?.gross_profit_usd_total ?? grossProfitCostTotal), - gross_margin_base_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.base_price_usd), - gross_margin_cost_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.cost_price_usd), - gross_margin_base_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.base_price_usd), - gross_margin_cost_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.cost_price_usd), - gross_margin_base_365d: marginFromSalesCost(row?.sales_usd_365d, row?.sales_qty_365d, row?.base_price_usd), - gross_margin_cost_365d: marginFromSalesCost(row?.sales_usd_365d, row?.sales_qty_365d, row?.cost_price_usd), - gross_margin_base_total: marginFromSalesCost(salesUSDTotal, salesQtyTotal, basePrice), - gross_margin_cost_total: marginFromSalesCost(salesUSDTotal, salesQtyTotal, costPrice) + const next = normalizeProductCostFields(row) + const out = { + ...next, + avg_price_usd_365d: Number(next?.sales_qty_365d || 0) > 0 ? Number(next?.sales_usd_365d || 0) / Number(next?.sales_qty_365d || 0) : 0, + stock_turnover_90d: existingOrStockTurnover(next?.stock_turnover_90d, next?.sales_qty_90d, next?.stock_qty), + stock_turnover_180d: existingOrStockTurnover(next?.stock_turnover_180d, next?.sales_qty_180d, next?.stock_qty), + stock_turnover_365d: existingOrStockTurnover(next?.stock_turnover_365d, next?.sales_qty_365d, next?.stock_qty), + stock_turnover_total: existingOrStockTurnover(next?.stock_turnover_total, next?.sales_qty_total, next?.stock_qty) } + for (const suffix of ['90d', '180d', '365d', 'total']) { + applyPeriodProfitFields(out, suffix) + } + return out } function withGeneralMargins (row) { - const salesQtyTotal = Number(row?.sales_qty_total || 0) - const salesUSDTotal = Number(row?.sales_usd_total || 0) - const basePrice = Number(row?.base_price_usd || 0) - const costPrice = Number(row?.cost_price_usd || 0) - const grossProfitBaseTotal = salesQtyTotal > 0 ? salesUSDTotal - (salesQtyTotal * basePrice) : 0 - const grossProfitCostTotal = salesQtyTotal > 0 ? salesUSDTotal - (salesQtyTotal * costPrice) : 0 + const out = normalizeProductCostFields(row) + applyPeriodProfitFields(out, 'total') return { - ...row, - avg_price_usd_total: salesQtyTotal > 0 ? salesUSDTotal / salesQtyTotal : Number(row?.avg_price_usd_total || 0), - stock_turnover_total: existingOrStockTurnover(row?.stock_turnover_total, row?.sales_qty_total, row?.stock_qty), - gross_profit_base_usd_total: grossProfitBaseTotal, - gross_profit_cost_usd_total: grossProfitCostTotal, - gross_profit_usd_total: Number(row?.gross_profit_usd_total ?? grossProfitCostTotal), - gross_margin_base_total: marginFromSalesCost(salesUSDTotal, salesQtyTotal, basePrice), - gross_margin_cost_total: marginFromSalesCost(salesUSDTotal, salesQtyTotal, costPrice) + ...out, + stock_turnover_total: existingOrStockTurnover(out?.stock_turnover_total, out?.sales_qty_total, out?.stock_qty) } } +function normalizeProductCostFields (row) { + const next = { ...row } + normalizeProductCostPairFields(next, 'cost_price_usd', 'base_price_usd') + for (const suffix of ['90d', '180d', '365d', 'total']) { + normalizeProductCostPairFields(next, `cost_price_usd_${suffix}`, `base_price_usd_${suffix}`) + } + return next +} + +function normalizeProductCostPairFields (row, costField, baseField) { + const hasCost = Object.prototype.hasOwnProperty.call(row || {}, costField) + const hasBase = Object.prototype.hasOwnProperty.call(row || {}, baseField) + if (!hasCost && !hasBase) return + let costPrice = Number(row?.[costField] || 0) + let basePrice = Number(row?.[baseField] || 0) + if (costPrice > 0 && basePrice > 0 && costPrice > basePrice) { + const tmp = costPrice + costPrice = basePrice + basePrice = tmp + } + if (hasCost || costPrice > 0) row[costField] = costPrice + if (hasBase || basePrice > 0) row[baseField] = basePrice +} + +function periodCostPair (row, suffix) { + const costField = `cost_price_usd_${suffix}` + const baseField = `base_price_usd_${suffix}` + let costPrice = Number(row?.[costField] || 0) + let basePrice = Number(row?.[baseField] || 0) + if (costPrice <= 0) costPrice = Number(row?.cost_price_usd || 0) + if (basePrice <= 0) basePrice = Number(row?.base_price_usd || 0) + if (costPrice > 0 && basePrice > 0 && costPrice > basePrice) { + const tmp = costPrice + costPrice = basePrice + basePrice = tmp + } + return { costPrice, basePrice } +} + +function applyPeriodProfitFields (row, suffix) { + const qty = Number(row?.[`sales_qty_${suffix}`] || 0) + const salesUSD = Number(row?.[`sales_usd_${suffix}`] || 0) + const { costPrice, basePrice } = periodCostPair(row, suffix) + row[`base_price_usd_${suffix}`] = basePrice + row[`cost_price_usd_${suffix}`] = costPrice + if (qty > 0) { + row[`avg_price_usd_${suffix}`] = salesUSD / qty + row[`unit_profit_base_${suffix}`] = salesUSD / qty - basePrice + row[`unit_profit_cost_${suffix}`] = salesUSD / qty - costPrice + row[`gross_profit_base_usd_${suffix}`] = salesUSD - qty * basePrice + row[`gross_profit_cost_usd_${suffix}`] = salesUSD - qty * costPrice + row[`gross_profit_usd_${suffix}`] = row[`gross_profit_cost_usd_${suffix}`] + } else { + row[`unit_profit_base_${suffix}`] = 0 + row[`unit_profit_cost_${suffix}`] = 0 + row[`gross_profit_base_usd_${suffix}`] = 0 + row[`gross_profit_cost_usd_${suffix}`] = 0 + row[`gross_profit_usd_${suffix}`] = 0 + } + row[`gross_margin_base_${suffix}`] = marginFromSalesCost(salesUSD, qty, basePrice) + row[`gross_margin_cost_${suffix}`] = marginFromSalesCost(salesUSD, qty, costPrice) +} + function stockTurnover (salesQty, stockQty) { const stock = Number(stockQty || 0) if (stock <= 0) return 0 @@ -4461,11 +4555,21 @@ function formatProductCell (row, name) { case 'gross_profit_usd_90d': case 'gross_profit_usd_180d': case 'gross_profit_usd_total': + case 'gross_profit_base_usd_90d': + case 'gross_profit_cost_usd_90d': + case 'gross_profit_base_usd_180d': + case 'gross_profit_cost_usd_180d': + case 'gross_profit_base_usd_365d': + case 'gross_profit_cost_usd_365d': + case 'gross_profit_base_usd_total': + case 'gross_profit_cost_usd_total': return formatMoney(row[name], 'USD') case 'gross_margin_base_90d': case 'gross_margin_cost_90d': case 'gross_margin_base_180d': case 'gross_margin_cost_180d': + case 'gross_margin_base_365d': + case 'gross_margin_cost_365d': case 'gross_margin_base_total': case 'gross_margin_cost_total': case 'gross_margin_90d': @@ -5045,7 +5149,7 @@ async function loadBackendGroupedRows (options = {}) { function normalizeGroupedDisplayRow (tabKey, row) { const nextRow = { ...row } normalizeGroupedProductHierarchyValues(nextRow) - return nextRow + return withPeriodScores(withGeneralMargins(withProductMargins(nextRow))) } function normalizeGroupedProductHierarchyValues (row) {