From c536b85a4cd1038b5dcc219bb2bfb79d3f835cd2 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Fri, 3 Jul 2026 00:01:42 +0300 Subject: [PATCH] ui: update ProductPerformanceProfitability page to enhance table views and tabs management --- svc/main.go | 10 + svc/middlewares/authz_v2.go | 1 + svc/queries/product_performance.go | 414 +++++++ svc/routes/product_images.go | 149 +++ svc/routes/product_performance.go | 41 + ui/src/pages/OrderPriceList.vue | 71 +- .../pages/ProductPerformanceProfitability.vue | 1100 ++++++++++++++--- ui/src/pages/ProductStockByAttributes.vue | 65 +- ui/src/pages/ProductStockQuery.vue | 64 +- 9 files changed, 1716 insertions(+), 199 deletions(-) diff --git a/svc/main.go b/svc/main.go index c053f56..de0ffb7 100644 --- a/svc/main.go +++ b/svc/main.go @@ -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", diff --git a/svc/middlewares/authz_v2.go b/svc/middlewares/authz_v2.go index 25f52b5..df40f55 100644 --- a/svc/middlewares/authz_v2.go +++ b/svc/middlewares/authz_v2.go @@ -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"}, } diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index e0ca0d7..ec638b2 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -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 diff --git a/svc/routes/product_images.go b/svc/routes/product_images.go index 46156fa..2bcd7e5 100644 --- a/svc/routes/product_images.go +++ b/svc/routes/product_images.go @@ -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) { diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go index 3d32cb0..a3694c9 100644 --- a/svc/routes/product_performance.go +++ b/svc/routes/product_performance.go @@ -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 == "" { diff --git a/ui/src/pages/OrderPriceList.vue b/ui/src/pages/OrderPriceList.vue index 333b3a0..365f0bc 100644 --- a/ui/src/pages/OrderPriceList.vue +++ b/ui/src/pages/OrderPriceList.vue @@ -1222,49 +1222,58 @@ async function reloadData ({ page = 1 } = {}) { } async function loadImagesForRows (list) { - const targets = [] + const items = [] + const rowByKey = new Map() const seen = new Set() for (const row of list) { const key = `${row.productCode}|${row.dim1 || 0}|${row.dim3 || 0}` if (!row.productCode || seen.has(key)) continue seen.add(key) - targets.push({ row, key }) - } - const concurrency = 12 - let cursor = 0 - let loaded = 0 - const workers = Array.from({ length: Math.min(concurrency, targets.length) }, async () => { - for (;;) { - const target = targets[cursor] - cursor += 1 - if (!target) return - const { row, key } = target if (imageCache.has(key)) { row.imageUrl = imageCache.get(key) continue } + rowByKey.set(key, row) + items.push({ + key, + code: row.productCode, + dim1: row.dim1 || '', + dim3: row.dim3 || '' + }) + } + if (!items.length) { + rows.value = [...rows.value] + return + } + + const chunks = [] + for (let i = 0; i < items.length; i += 250) chunks.push(items.slice(i, i + 250)) + for (const chunk of chunks) { try { - const res = await api.get('/product-images', { - params: { - code: row.productCode, - dim1_id: row.dim1 || '', - dim3_id: row.dim3 || '' - }, - timeout: 15000 - }) - const first = Array.isArray(res?.data) ? res.data[0] : null - const url = resolveProductImageUrl(first) - imageCache.set(key, url) - row.imageUrl = url - imageListCache.set(key, Array.isArray(res?.data) ? res.data : []) + const res = await api.post('/product-images/batch', { items: chunk }, { timeout: 30000 }) + const returned = new Set() + for (const item of Array.isArray(res?.data) ? res.data : []) { + const key = toText(item?.key) + if (!key) continue + returned.add(key) + const list = Array.isArray(item.images) ? item.images : [] + const url = resolveProductImageUrl(list[0]) + imageCache.set(key, url) + imageListCache.set(key, list) + const row = rowByKey.get(key) + if (row) row.imageUrl = url + } + for (const item of chunk) { + if (!returned.has(item.key)) { + imageCache.set(item.key, '') + imageListCache.set(item.key, []) + } + } } catch { - imageCache.set(key, '') + for (const item of chunk) imageCache.set(item.key, '') } - loaded += 1 - if (loaded % 12 === 0) rows.value = [...rows.value] - } - }) - await Promise.all(workers) + rows.value = [...rows.value] + } rows.value = [...rows.value] } diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 731d93c..91b2f02 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -76,7 +76,7 @@ flat bordered row-key="row_key" - class="performance-table bg-white" + class="performance-table sticky-dim-table sticky-dim-7 bg-white" :rows="filteredGeneralRows" :columns="generalColumns" :loading="loading" @@ -155,12 +155,13 @@