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
+10
View File
@@ -829,6 +829,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"order", "view",
wrapV3(http.HandlerFunc(routes.GetProductImagesHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/product-images/batch", "POST",
"order", "view",
wrapV3(http.HandlerFunc(routes.PostProductImagesBatchHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/product-images/{id}/content", "GET",
"order", "view",
@@ -984,6 +989,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "view",
wrapV3(routes.GetProductPerformanceSalesBreakdownHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/product-performance/grouped", "GET",
"pricing", "view",
wrapV3(routes.GetProductPerformanceGroupedHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/product-performance/sales-details", "GET",
"pricing", "view",
+1
View File
@@ -66,6 +66,7 @@ var routeMetaCache sync.Map
var routeMetaFallback = map[string]routeMeta{
"GET /api/product-images": {module: "order", action: "view"},
"POST /api/product-images/batch": {module: "order", action: "view"},
"GET /api/product-images/{id}/content": {module: "order", action: "view"},
"GET /api/product-stock-query-by-attributes": {module: "order", action: "view"},
}
+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
+149
View File
@@ -27,6 +27,23 @@ type ProductImageItem struct {
FullURL string `json:"full_url,omitempty"`
}
type ProductImageBatchItem struct {
Key string `json:"key"`
Code string `json:"code"`
Dim1 string `json:"dim1"`
Dim3 string `json:"dim3"`
Limit int `json:"limit,omitempty"`
}
type ProductImageBatchRequest struct {
Items []ProductImageBatchItem `json:"items"`
}
type ProductImageBatchResponseItem struct {
Key string `json:"key"`
Images []ProductImageItem `json:"images"`
}
var uuidPattern = regexp.MustCompile(`(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
func normalizeDimParam(v string) string {
@@ -119,6 +136,138 @@ func extractImageUUID(storagePath, fileName string) string {
return ""
}
// POST /api/product-images/batch
func PostProductImagesBatchHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
reqID := strings.TrimSpace(r.Header.Get("X-Request-ID"))
if reqID == "" {
reqID = uuid.NewString()
}
w.Header().Set("X-Request-ID", reqID)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var req ProductImageBatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz gorsel batch istegi: "+err.Error(), http.StatusBadRequest)
return
}
if len(req.Items) > 300 {
req.Items = req.Items[:300]
}
cleanItems := make([]ProductImageBatchItem, 0, len(req.Items))
seen := map[string]bool{}
for _, item := range req.Items {
item.Key = strings.TrimSpace(item.Key)
item.Code = strings.TrimSpace(item.Code)
item.Dim1 = normalizeDimParam(item.Dim1)
item.Dim3 = normalizeDimParam(item.Dim3)
if item.Key == "" {
item.Key = item.Code + "|" + item.Dim1 + "|" + item.Dim3
}
if item.Code == "" || seen[item.Key] {
continue
}
seen[item.Key] = true
cleanItems = append(cleanItems, item)
}
if len(cleanItems) == 0 {
_ = json.NewEncoder(w).Encode([]ProductImageBatchResponseItem{})
return
}
payload, err := json.Marshal(cleanItems)
if err != nil {
http.Error(w, "Gorsel batch hazirlanamadi: "+err.Error(), http.StatusInternalServerError)
return
}
rows, err := pg.Query(`
WITH req AS (
SELECT *
FROM jsonb_to_recordset($1::jsonb) AS x(key text, code text, dim1 text, dim3 text)
),
matched_item AS (
SELECT DISTINCT ON (r.key)
r.key,
r.code,
r.dim1,
r.dim3,
m.id AS mmitem_id
FROM req r
JOIN mmitem m ON
UPPER(REPLACE(COALESCE(m.code,''), ' ', '')) = UPPER(REPLACE(COALESCE(r.code,''), ' ', ''))
OR UPPER(REPLACE(REGEXP_REPLACE(COALESCE(m.code,''), '^.*-', ''), ' ', '')) =
UPPER(REPLACE(REGEXP_REPLACE(COALESCE(r.code,''), '^.*-', ''), ' ', ''))
ORDER BY r.key, m.id
),
ranked AS (
SELECT
mi.key,
b.id,
COALESCE(b.file_name,'') AS file_name,
COALESCE(b.file_size,0) AS file_size,
COALESCE(b.storage_path,'') AS storage_path,
ROW_NUMBER() OVER (
PARTITION BY mi.key
ORDER BY
CASE
WHEN mi.dim1 <> '' AND mi.dim3 <> '' AND COALESCE(b.dimval1::text,'') = mi.dim1 AND COALESCE(b.dimval3::text,'') = mi.dim3 THEN 0
WHEN mi.dim1 <> '' AND COALESCE(b.dimval1::text,'') = mi.dim1 AND COALESCE(b.dimval3::text,'') = '' THEN 1
WHEN COALESCE(b.dimval1::text,'') = '' AND COALESCE(b.dimval3::text,'') = '' THEN 2
ELSE 3
END,
COALESCE(b.sort_order,999999),
b.id
) AS rn
FROM matched_item mi
JOIN dfblob b ON b.src_table='mmitem'
AND b.typ='img'
AND b.src_id=mi.mmitem_id
)
SELECT key, id, file_name, file_size, storage_path
FROM ranked
WHERE rn <= 1
ORDER BY key, rn
`, string(payload))
if err != nil {
slog.Error("product_images.batch.query_failed", "req_id", reqID, "err", err.Error())
http.Error(w, "Gorsel batch sorgu hatasi: "+err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
grouped := map[string][]ProductImageItem{}
for rows.Next() {
var key string
var it ProductImageItem
if err := rows.Scan(&key, &it.ID, &it.FileName, &it.FileSize, &it.Storage); err != nil {
continue
}
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
it.UUID = u
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
it.FullURL = "/uploads/image/" + u + ".jpg"
}
grouped[key] = append(grouped[key], it)
}
if err := rows.Err(); err != nil {
http.Error(w, "Gorsel batch okuma hatasi: "+err.Error(), http.StatusInternalServerError)
return
}
out := make([]ProductImageBatchResponseItem, 0, len(cleanItems))
for _, item := range cleanItems {
out = append(out, ProductImageBatchResponseItem{
Key: item.Key,
Images: grouped[item.Key],
})
}
_ = json.NewEncoder(w).Encode(out)
}
}
// GET /api/product-images?code=...&dim1=...&dim3=...
func GetProductImagesHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+41
View File
@@ -261,6 +261,47 @@ func GetProductPerformanceSalesBreakdownHandler(pg *sql.DB) http.HandlerFunc {
}
}
func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
defer cancel()
expanded := map[string]bool{}
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
key = strings.TrimSpace(key)
if key != "" {
expanded[key] = true
}
}
rows, err := queries.ListProductPerformanceGrouped(ctx, pg, queries.ProductPerformanceGroupedRequest{
Mode: strings.TrimSpace(r.URL.Query().Get("mode")),
GroupLevels: splitCSVQuery(r.URL.Query().Get("group_levels")),
ExpandedKeys: expanded,
Limit: intQuery(r, "limit", 50000),
})
if err != nil {
http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(rows)
}
}
func splitCSVQuery(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func intQuery(r *http.Request, key string, fallback int) int {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {