ui: update ProductPerformanceProfitability page to enhance table views and tabs management
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user