ui: update ProductPerformanceProfitability page to enhance table views and tabs management

This commit is contained in:
M_Kececi
2026-07-03 00:01:42 +03:00
parent d419f52e3c
commit c536b85a4c
9 changed files with 1716 additions and 199 deletions
+414
View File
@@ -5,6 +5,7 @@ import (
"bssapp-backend/models"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"sort"
@@ -2583,6 +2584,419 @@ LIMIT $1
return out, rows.Err()
}
type ProductPerformanceGroupedRequest struct {
Mode string
GroupLevels []string
ExpandedKeys map[string]bool
Limit int
}
func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) {
if req.Limit <= 0 || req.Limit > 50000 {
req.Limit = 50000
}
levels := sanitizeProductPerformanceGroupLevels(req.GroupLevels)
if len(levels) == 0 {
levels = defaultProductPerformanceGroupLevels(req.Mode)
}
sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(sourceRows))
appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys)
return out, nil
}
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
switch mode {
case "products":
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
return structsToMaps(rows), err
case "idle":
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
if err != nil {
return nil, err
}
return productPerformanceIdleSourceRows(structsToMaps(rows)), nil
case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product":
rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, mode, limit)
return structsToMaps(rows), err
case "order_product_customers":
rows, err := ListProductPerformanceOrderProductCustomers(ctx, pg, limit)
return structsToMaps(rows), err
case "order_market_details":
rows, err := ListProductPerformanceOrderMarketDetails(ctx, pg, limit)
return structsToMaps(rows), err
default:
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
return structsToMaps(rows), err
}
}
func productPerformanceIdleSourceRows(rows []map[string]any) []map[string]any {
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
stockQty := floatFromMap(row, "stock_qty")
salesQty90 := floatFromMap(row, "sales_qty_90d")
stockDays90 := floatFromMap(row, "stock_days_90d")
if stockQty <= 0 {
continue
}
if stringFromMap(row, "performance_bucket") != "STOK_RISKI" && salesQty90 != 0 && stockDays90 <= 180 {
continue
}
next := cloneMap(row)
next["idle_cost_usd"] = stockQty * floatFromMap(row, "cost_price_usd")
out = append(out, next)
}
return out
}
func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map[string]any, levels []string, level int, parentKeys []string, expandedKeys map[string]bool) {
if level >= len(levels) {
*out = append(*out, sourceRows...)
return
}
field := levels[level]
grouped := make(map[string][]map[string]any)
for _, row := range sourceRows {
value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
grouped[value] = append(grouped[value], row)
}
values := make([]string, 0, len(grouped))
for value := range grouped {
values = append(values, value)
}
sort.Slice(values, func(i, j int) bool {
return strings.Compare(values[i], values[j]) < 0
})
for _, value := range values {
keyPart := field + ":" + value
key := strings.Join(append(parentKeys, keyPart), "|")
groupRows := grouped[value]
*out = append(*out, makeProductPerformanceGroupedRow(key, level, field, value, groupRows))
if expandedKeys[key] {
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, append(parentKeys, keyPart), expandedKeys)
}
}
}
func makeProductPerformanceGroupedRow(key string, level int, field, value string, rows []map[string]any) map[string]any {
row := aggregateProductPerformanceRows(rows)
row["__group"] = true
row["row_key"] = "group|" + key
row["key"] = key
row["level"] = level
row["group_field"] = field
row["group_value"] = value
row["label"] = value
row["count"] = len(rows)
row[field] = value
row["recommendation"] = fmt.Sprintf("%d satır", len(rows))
image := firstProductPerformanceImageSource(rows)
row["image_product_code"] = stringFromMap(image, "product_code")
row["image_color_code"] = stringFromMap(image, "color_code")
row["image_yaka_kodu"] = stringFromMap(image, "yaka_kodu")
return row
}
func aggregateProductPerformanceRows(rows []map[string]any) map[string]any {
out := map[string]any{}
for _, row := range rows {
for key, value := range row {
if key == "row_key" || key == "key" {
continue
}
if shouldSumProductPerformanceField(key) {
out[key] = floatFromAny(out[key]) + floatFromAny(value)
} else if _, ok := out[key]; !ok && !shouldAverageProductPerformanceField(key) {
out[key] = value
}
}
}
for _, row := range rows {
for key := range row {
if shouldAverageProductPerformanceField(key) {
out[key] = weightedAverageProductPerformanceRows(rows, key, productPerformanceMetricWeightField(key))
}
}
}
deriveProductPerformanceGroupMetrics(out)
out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket")
return out
}
func deriveProductPerformanceGroupMetrics(out map[string]any) {
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 stockQty > 0 {
out["stock_turnover_"+suffix] = qty / stockQty
}
if qty > 0 {
out["avg_price_usd_"+suffix] = sales / qty
}
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
}
}
}
orderUSD := floatFromMap(out, "order_usd")
if orderUSD > 0 {
out["expected_margin_base"] = floatFromMap(out, "expected_profit_base_usd") / orderUSD
out["expected_margin_cost"] = floatFromMap(out, "expected_profit_cost_usd") / orderUSD
}
orderQty := floatFromMap(out, "order_qty")
if orderQty > 0 {
out["avg_order_price_usd"] = orderUSD / orderQty
}
}
func sanitizeProductPerformanceGroupLevels(levels []string) []string {
allowed := map[string]bool{
"kategori": true, "askili_yan": true, "urun_ilk_grubu": true, "urun_ana_grubu": true, "urun_alt_grubu": true,
"product_code": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true,
"country": true, "customer_segment": true,
}
out := make([]string, 0, len(levels))
seen := map[string]bool{}
for _, level := range levels {
level = strings.TrimSpace(level)
if allowed[level] && !seen[level] {
out = append(out, level)
seen[level] = true
}
}
return out
}
func defaultProductPerformanceGroupLevels(mode string) []string {
switch mode {
case "sales_color_yaka_market_customer":
return []string{"color_code", "yaka_kodu", "market_key", "customer_code"}
case "idle":
return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code"}
case "sales_product_country_segment_market_customer":
return []string{"product_code", "country", "customer_segment", "market_key", "customer_code"}
case "sales_market_customer_product":
return []string{"market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
case "sales_country_segment_market_customer_product":
return []string{"country", "customer_segment", "market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
case "order_product_customers":
return []string{"product_code", "market_key", "customer_code"}
case "order_market_details":
return []string{"market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
default:
return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "market_key", "product_code"}
}
}
func mapGroupValue(row map[string]any, field string) string {
switch field {
case "urun_ilk_grubu":
if value := stringFromMap(row, "urun_ilk_grubu"); value != "" {
return value
}
return stringFromMap(row, "yas_grubu")
case "urun_ana_grubu":
if value := stringFromMap(row, "urun_ana_grubu"); value != "" {
return value
}
return stringFromMap(row, "seri")
case "market_key":
return displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
default:
return stringFromMap(row, field)
}
}
func shouldSumProductPerformanceField(field string) bool {
return strings.HasSuffix(field, "_qty") ||
strings.HasSuffix(field, "_usd") ||
strings.HasSuffix(field, "_count") ||
strings.HasSuffix(field, "_value_usd") ||
field == "stock_qty" ||
field == "line_count" ||
field == "invoice_count" ||
field == "order_count" ||
field == "product_count" ||
field == "market_count" ||
field == "customer_count" ||
field == "overdue_qty" ||
field == "net_stock_after_order" ||
field == "idle_cost_usd"
}
func shouldAverageProductPerformanceField(field string) bool {
return strings.HasPrefix(field, "avg_") ||
strings.HasPrefix(field, "unit_") ||
strings.HasPrefix(field, "base_price") ||
strings.HasPrefix(field, "cost_price") ||
strings.HasPrefix(field, "gross_margin") ||
strings.HasPrefix(field, "expected_margin") ||
strings.HasPrefix(field, "sales_index") ||
strings.HasPrefix(field, "performance_score") ||
strings.HasPrefix(field, "stock_days") ||
strings.HasPrefix(field, "stock_turnover")
}
func productPerformanceMetricWeightField(field string) string {
if strings.Contains(field, "_180d") {
return "sales_qty_180d"
}
if strings.Contains(field, "_365d") {
return "sales_qty_365d"
}
if strings.Contains(field, "_total") {
return "sales_qty_total"
}
if strings.Contains(field, "order") || strings.Contains(field, "expected_") {
return "order_qty"
}
if strings.Contains(field, "stock") {
return "stock_qty"
}
return "sales_qty_90d"
}
func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qtyField string) float64 {
var weighted, qty, sum float64
var count int
for _, row := range rows {
value := floatFromMap(row, valueField)
weight := floatFromMap(row, qtyField)
if weight > 0 {
weighted += value * weight
qty += weight
}
if value != 0 {
sum += value
count++
}
}
if qty > 0 {
return weighted / qty
}
if count > 0 {
return sum / float64(count)
}
return 0
}
func dominantProductPerformanceValue(rows []map[string]any, field string) string {
counts := map[string]int{}
for _, row := range rows {
value := stringFromMap(row, field)
if value != "" {
counts[value]++
}
}
var best string
var bestCount int
for value, count := range counts {
if count > bestCount {
best = value
bestCount = count
}
}
return best
}
func firstProductPerformanceImageSource(rows []map[string]any) map[string]any {
for _, row := range rows {
if stringFromMap(row, "product_code") != "" {
return row
}
}
if len(rows) > 0 {
return rows[0]
}
return map[string]any{}
}
func structsToMaps[T any](rows []T) []map[string]any {
raw, err := json.Marshal(rows)
if err != nil {
return nil
}
var out []map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return nil
}
return out
}
func cloneMap(row map[string]any) map[string]any {
out := make(map[string]any, len(row))
for key, value := range row {
out[key] = value
}
return out
}
func normalizeProductPerformanceGroupValue(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "-"
}
return value
}
func displayProductPerformanceMarketName(value string) string {
parts := strings.Split(value, "|")
for i := len(parts) - 1; i >= 0; i-- {
part := strings.TrimSpace(parts[i])
if part != "" {
return part
}
}
return ""
}
func stringFromMap(row map[string]any, field string) string {
value, ok := row[field]
if !ok || value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func floatFromMap(row map[string]any, field string) float64 {
return floatFromAny(row[field])
}
func floatFromAny(value any) float64 {
switch v := value.(type) {
case float64:
return v
case float32:
return float64(v)
case int:
return float64(v)
case int64:
return float64(v)
case json.Number:
f, _ := v.Float64()
return f
default:
return 0
}
}
func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
if limit <= 0 || limit > 500 {
limit = 100