From 27934bccdf9255073cd657e9454bf4a4fbbfa817 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Sun, 5 Jul 2026 05:16:17 +0300 Subject: [PATCH] Fix product performance grouped JSON build --- svc/queries/product_performance.go | 160 ++++++-- svc/routes/product_images.go | 23 +- svc/routes/product_performance.go | 39 +- .../pages/ProductPerformanceProfitability.vue | 362 +++++++++++++----- ui/src/stores/productPerformanceStore.js | 64 +++- 5 files changed, 486 insertions(+), 162 deletions(-) diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index 3e78366..0df0fa0 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -3465,11 +3465,13 @@ LIMIT $1 } type ProductPerformanceGroupedRequest struct { - Mode string - GroupLevels []string - ExpandedKeys map[string]bool - Limit int - MainGroup string + Mode string + GroupLevels []string + ExpandedKeys map[string]bool + ExpandThroughLevel int + Limit int + MainGroup string + Filters map[string][]string } func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) { @@ -3480,6 +3482,9 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP if len(levels) == 0 { levels = defaultProductPerformanceGroupLevels(req.Mode) } + if req.ExpandThroughLevel > len(levels)-2 { + req.ExpandThroughLevel = len(levels) - 2 + } if out, ok, err := listProductPerformanceGroupedSQL(ctx, pg, req, levels); ok || err != nil { return out, err @@ -3489,14 +3494,16 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP if err != nil { return nil, err } + sourceRows = filterProductPerformanceGroupedRows(sourceRows, req.Filters) out := make([]map[string]any, 0, len(sourceRows)) - appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys) + appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys, req.ExpandThroughLevel) return out, nil } type productPerformanceSQLGroupFilter struct { - Field string - Value string + Field string + Value string + Values []string } func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) { @@ -3509,25 +3516,28 @@ func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req Produ } out := make([]map[string]any, 0, 512) filters := productPerformanceGroupedBaseFilters(req) - err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, 0, []string{"tab:" + mode}, filters, req.ExpandedKeys, req.Limit) + err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, 0, []string{"tab:" + mode}, filters, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit) return out, true, err } func productPerformanceGroupedBaseFilters(req ProductPerformanceGroupedRequest) []productPerformanceSQLGroupFilter { + filters := make([]productPerformanceSQLGroupFilter, 0, len(req.Filters)+1) mainGroup := strings.TrimSpace(req.MainGroup) - if strings.TrimSpace(req.Mode) != "product_detail" || mainGroup == "" { - return nil + if strings.TrimSpace(req.Mode) == "product_detail" && mainGroup != "" { + filters = append(filters, productPerformanceSQLGroupFilter{Field: "urun_ana_grubu", Value: mainGroup}) } - return []productPerformanceSQLGroupFilter{{Field: "urun_ana_grubu", Value: mainGroup}} + for field, values := range req.Filters { + cleanValues := cleanProductPerformanceFilterValues(values) + if len(cleanValues) == 0 { + continue + } + filters = append(filters, productPerformanceSQLGroupFilter{Field: field, Values: cleanValues}) + } + return filters } -func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, visualLevel int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, limit int) error { +func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, visualLevel int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, expandThroughLevel int, limit int) error { if level >= len(levels) { - rows, err := queryProductPerformanceSQLLeafRows(ctx, pg, mode, filters, limit) - if err != nil { - return err - } - *out = append(*out, rows...) return nil } @@ -3540,7 +3550,7 @@ func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out value := stringFromMap(row, "group_value") if shouldSkipProductPerformanceGroupValue(field, value) { nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value}) - if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel, parentKeys, nextFilters, expandedKeys, limit); err != nil { + if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel, parentKeys, nextFilters, expandedKeys, expandThroughLevel, limit); err != nil { return err } continue @@ -3556,9 +3566,9 @@ func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out row["__group"] = true row[field] = value *out = append(*out, row) - if expandedKeys[key] { + if expandedKeys[key] || level <= expandThroughLevel { nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value}) - if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel+1, append(parentKeys, keyPart), nextFilters, expandedKeys, limit); err != nil { + if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel+1, append(parentKeys, keyPart), nextFilters, expandedKeys, expandThroughLevel, limit); err != nil { return err } } @@ -3889,7 +3899,9 @@ func productPerformanceSQLGroupExpr(field string) (string, bool) { return "CASE WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE COALESCE(urun_ilk_grubu,'') END", true case "askili_yan": return "CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END", true - case "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu": + case "urun_ana_grubu": + return "COALESCE(NULLIF(btrim(COALESCE(urun_ana_grubu,'')), ''), NULLIF(btrim(COALESCE(urun_alt_grubu,'')), ''), NULLIF(btrim(COALESCE(kategori,'')), ''), '')", true + case "kategori", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu", "performance_bucket": return field, true case "color_yaka": return "concat_ws('/', NULLIF(btrim(COALESCE(color_code,'')), ''), NULLIF(btrim(COALESCE(yaka_kodu,'')), ''))", true @@ -3920,12 +3932,93 @@ func productPerformanceSQLFilterWhere(filters []productPerformanceSQLGroupFilter if !ok { return "", nil, fmt.Errorf("unsupported product performance filter field: %s", filter.Field) } - args = append(args, normalizeProductPerformanceGroupValue(filter.Value)) - parts = append(parts, fmt.Sprintf("COALESCE(%s, '') = $%d", expr, len(args))) + values := filter.Values + if len(values) == 0 && strings.TrimSpace(filter.Value) != "" { + values = []string{filter.Value} + } + values = cleanProductPerformanceFilterValues(values) + if len(values) == 0 { + continue + } + args = append(args, pq.Array(values)) + parts = append(parts, fmt.Sprintf("COALESCE(%s, '') = ANY($%d)", expr, len(args))) + } + if len(parts) == 0 { + return "", nil, nil } return "WHERE " + strings.Join(parts, " AND "), args, nil } +func cleanProductPerformanceFilterValues(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + clean := normalizeProductPerformanceGroupValue(value) + if clean == "" || seen[clean] { + continue + } + seen[clean] = true + out = append(out, clean) + if len(out) >= 300 { + break + } + } + return out +} + +func filterProductPerformanceGroupedRows(rows []map[string]any, filters map[string][]string) []map[string]any { + if len(filters) == 0 || len(rows) == 0 { + return rows + } + cleanFilters := map[string]map[string]bool{} + for field, values := range filters { + cleanValues := cleanProductPerformanceFilterValues(values) + if len(cleanValues) == 0 { + continue + } + set := map[string]bool{} + for _, value := range cleanValues { + set[value] = true + } + cleanFilters[field] = set + } + if len(cleanFilters) == 0 { + return rows + } + out := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + matches := true + for field, allowed := range cleanFilters { + if !allowed[productPerformanceGroupedFilterValue(row, field)] { + matches = false + break + } + } + if matches { + out = append(out, row) + } + } + return out +} + +func productPerformanceGroupedFilterValue(row map[string]any, field string) string { + switch field { + case "urun_ana_grubu": + value := strings.TrimSpace(stringFromMap(row, "urun_ana_grubu")) + if value != "" { + return normalizeProductPerformanceGroupValue(value) + } + if value = strings.TrimSpace(stringFromMap(row, "urun_alt_grubu")); value != "" { + return normalizeProductPerformanceGroupValue(value) + } + return normalizeProductPerformanceGroupValue(stringFromMap(row, "kategori")) + case "market_key", "color_yaka", "urun_ilk_grubu", "askili_yan": + return normalizeProductPerformanceGroupValue(mapGroupValue(row, field)) + default: + return normalizeProductPerformanceGroupValue(stringFromMap(row, field)) + } +} + func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) { switch mode { case "products": @@ -3986,9 +4079,8 @@ func productPerformanceIdleSourceRows(rows []map[string]any) []map[string]any { return out } -func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map[string]any, levels []string, level int, visualLevel int, parentKeys []string, expandedKeys map[string]bool) { +func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map[string]any, levels []string, level int, visualLevel int, parentKeys []string, expandedKeys map[string]bool, expandThroughLevel int) { if level >= len(levels) { - *out = append(*out, sourceRows...) return } @@ -4010,14 +4102,14 @@ func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map for _, value := range values { groupRows := grouped[value] if shouldSkipProductPerformanceGroupValue(field, value) { - appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel, parentKeys, expandedKeys) + appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel, parentKeys, expandedKeys, expandThroughLevel) continue } keyPart := field + ":" + value key := strings.Join(append(parentKeys, keyPart), "|") *out = append(*out, makeProductPerformanceGroupedRow(key, visualLevel, field, value, groupRows)) - if expandedKeys[key] { - appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel+1, append(parentKeys, keyPart), expandedKeys) + if expandedKeys[key] || level <= expandThroughLevel { + appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel+1, append(parentKeys, keyPart), expandedKeys, expandThroughLevel) } } } @@ -4076,6 +4168,9 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m if key == "row_key" || key == "key" { continue } + if isProductPerformanceMarginField(key) { + continue + } if key == "stock_qty" { out[key] = distinctProductPerformanceVariantStockQty(rows) continue @@ -4093,6 +4188,9 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m } for _, row := range rows { for key := range row { + if isProductPerformanceMarginField(key) { + continue + } if shouldAverageProductPerformanceField(key) { out[key] = weightedAverageProductPerformanceRows(rows, key, productPerformanceMetricWeightField(key)) } @@ -4103,6 +4201,10 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m return out } +func isProductPerformanceMarginField(field string) bool { + return strings.HasPrefix(field, "gross_margin") +} + func distinctProductPerformanceVariantStockQty(rows []map[string]any) float64 { seen := map[string]bool{} total := 0.0 diff --git a/svc/routes/product_images.go b/svc/routes/product_images.go index b52326b..de340f2 100644 --- a/svc/routes/product_images.go +++ b/svc/routes/product_images.go @@ -141,18 +141,14 @@ func enrichProductImageItem(it *ProductImageItem) { if it == nil { return } + if it.ID > 0 { + 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" } - if it.StoredInDB { - it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID) - return - } - if resolved, _ := resolveStoragePath(it.Storage); resolved != "" { - it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID) - } } // POST /api/product-images/batch @@ -576,14 +572,25 @@ func resolveStoragePath(storagePath string) (string, []string) { raw = filepath.ToSlash(filepath.Clean(raw)) relUploads := filepath.FromSlash(filepath.Join("uploads", raw)) + baseName := filepath.Base(raw) + relImage := filepath.FromSlash(filepath.Join("uploads", "image", baseName)) + relImageThumb := filepath.FromSlash(filepath.Join("uploads", "image", "t300", baseName)) candidates := []string{ filepath.Clean(storagePath), filepath.FromSlash(filepath.Clean(strings.TrimPrefix(storagePath, "/"))), filepath.FromSlash(filepath.Clean(raw)), relUploads, + relImage, + relImageThumb, filepath.Join(".", relUploads), + filepath.Join(".", relImage), + filepath.Join(".", relImageThumb), filepath.Join("..", relUploads), + filepath.Join("..", relImage), + filepath.Join("..", relImageThumb), filepath.Join("..", "..", relUploads), + filepath.Join("..", "..", relImage), + filepath.Join("..", "..", relImageThumb), } if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" { @@ -591,6 +598,8 @@ func resolveStoragePath(storagePath string) (string, []string) { filepath.Join(root, raw), filepath.Join(root, relUploads), filepath.Join(root, "uploads", raw), + filepath.Join(root, relImage), + filepath.Join(root, relImageThumb), ) } diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go index 47e2f07..72374ed 100644 --- a/svc/routes/product_performance.go +++ b/svc/routes/product_performance.go @@ -273,7 +273,14 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels")) limit := intQuery(r, "limit", 50000) expanded := map[string]bool{} - const maxExpandedGroupKeys = 260 + filters := map[string][]string{} + expandThroughLevel := -1 + if rawExpandLevel := strings.TrimSpace(r.URL.Query().Get("expand_through_level")); rawExpandLevel != "" { + if parsed, err := strconv.Atoi(rawExpandLevel); err == nil { + expandThroughLevel = parsed + } + } + const maxExpandedGroupKeys = 1000 addExpandedKey := func(key string) { key = strings.TrimSpace(key) if key != "" && len(expanded) < maxExpandedGroupKeys { @@ -282,11 +289,13 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { } if r.Method == http.MethodPost { var body struct { - Mode string `json:"mode"` - GroupLevels []string `json:"group_levels"` - ExpandedKeys []string `json:"expanded_keys"` - Limit int `json:"limit"` - MainGroup string `json:"urun_ana_grubu"` + Mode string `json:"mode"` + GroupLevels []string `json:"group_levels"` + ExpandedKeys []string `json:"expanded_keys"` + ExpandThroughLevel *int `json:"expand_through_level"` + Limit int `json:"limit"` + MainGroup string `json:"urun_ana_grubu"` + Filters map[string][]string `json:"filters"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest) @@ -307,6 +316,12 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { for _, key := range body.ExpandedKeys { addExpandedKey(key) } + if body.ExpandThroughLevel != nil { + expandThroughLevel = *body.ExpandThroughLevel + } + if len(body.Filters) > 0 { + filters = body.Filters + } } else { for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") { addExpandedKey(key) @@ -314,11 +329,13 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { } rows, err := queries.ListProductPerformanceGrouped(ctx, pg, queries.ProductPerformanceGroupedRequest{ - Mode: mode, - GroupLevels: groupLevels, - ExpandedKeys: expanded, - Limit: limit, - MainGroup: mainGroup, + Mode: mode, + GroupLevels: groupLevels, + ExpandedKeys: expanded, + ExpandThroughLevel: expandThroughLevel, + Limit: limit, + MainGroup: mainGroup, + Filters: filters, }) if err != nil { http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError) diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index cefa9ee..37ce2a9 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -115,7 +115,7 @@ class="performance-table sticky-dim-table sticky-dim-7 bg-white" :rows="filteredGeneralRows" :columns="generalColumns" - :loading="loading" + :loading="activeTableLoading" v-model:pagination="tablePagination.general" virtual-scroll :virtual-scroll-item-size="productThumbVirtualItemSize" @@ -227,7 +227,7 @@ class="performance-table sticky-dim-table sticky-dim-8 bg-white" :rows="displayOrderProductCustomerTableRows" :columns="orderProductCustomerColumns" - :loading="loading" + :loading="activeTableLoading" v-model:pagination="tablePagination.order_product_customers" :sort-method="sortProductGroupedTableRows" virtual-scroll @@ -418,7 +418,7 @@ class="performance-table sticky-dim-table sticky-dim-10 bg-white" :rows="displayOrderMarketDetailTableRows" :columns="orderMarketDetailColumns" - :loading="loading" + :loading="activeTableLoading" v-model:pagination="tablePagination.order_market_details" :sort-method="sortProductGroupedTableRows" virtual-scroll @@ -561,7 +561,7 @@ ]" :rows="displayProductKpiTableRows" :columns="activeProductColumns" - :loading="loading" + :loading="activeTableLoading" v-model:pagination="activeProductPagination" :sort-method="sortProductGroupedTableRows" virtual-scroll @@ -717,7 +717,7 @@ class="performance-table sticky-dim-table sticky-dim-11 bg-white" :rows="displayIdleTableRows" :columns="idleColumns" - :loading="loading" + :loading="activeTableLoading" v-model:pagination="tablePagination.idle" :sort-method="sortProductGroupedTableRows" virtual-scroll @@ -904,7 +904,7 @@ class="performance-table sticky-dim-table sticky-dim-9 bg-white" :rows="filteredMarketRows" :columns="marketColumns" - :loading="loading" + :loading="activeTableLoading" v-model:pagination="tablePagination.markets" virtual-scroll :virtual-scroll-item-size="64" @@ -1011,7 +1011,7 @@ ]" :rows="displayActiveSalesBreakdownTableRows" :columns="visibleSalesBreakdownColumns" - :loading="loading" + :loading="activeTableLoading" v-model:pagination="activeSalesBreakdownPagination" :sort-method="sortProductGroupedTableRows" virtual-scroll @@ -1170,7 +1170,7 @@ class="performance-table sticky-dim-table sticky-dim-5 bg-white" :rows="filteredCustomerRows" :columns="customerColumns" - :loading="loading" + :loading="activeTableLoading" v-model:pagination="tablePagination.customers" virtual-scroll :virtual-scroll-item-size="64" @@ -1272,7 +1272,7 @@ class="performance-table sticky-dim-table sticky-dim-3 bg-white" :rows="filteredCountryRows" :columns="countryColumns" - :loading="loading" + :loading="activeTableLoading" v-model:pagination="tablePagination.countries" virtual-scroll :virtual-scroll-item-size="64" @@ -1412,7 +1412,7 @@ :name="idx" class="column no-wrap flex-center" > - +
@@ -1522,7 +1522,8 @@ const performanceStore = useProductPerformanceStore() function formatNumber (value, fraction = 2) { const n = Number(value || 0) - const digits = Math.max(0, Math.min(2, Number(fraction || 0))) + const requested = Number(fraction) + const digits = Number.isFinite(requested) ? Math.max(0, Math.min(4, requested)) : 2 return n.toLocaleString('tr-TR', { minimumFractionDigits: digits, maximumFractionDigits: digits @@ -1539,6 +1540,7 @@ function formatPercent (value) { const loading = ref(false) const rows = ref([]) +const detailMainGroupOptionRows = ref([]) const generalRows = ref([]) const orderAnalysisRows = ref([]) const orderMarketRows = ref([]) @@ -1635,6 +1637,24 @@ const maxAutoExpandLevelByTab = { order_product_customers: 8, order_market_details: 8 } +const backendGroupedFilterFields = new Set([ + 'kategori', + 'askili_yan', + 'urun_ilk_grubu', + 'urun_ana_grubu', + 'urun_alt_grubu', + 'product_code', + 'item_description', + 'color_code', + 'yaka_kodu', + 'color_yaka', + 'market_key', + 'country', + 'customer_segment', + 'customer_code', + 'customer_name', + 'performance_bucket' +]) const performanceTabs = [ { name: 'products', icon: 'dashboard', label: 'Genel Özet KPI' }, { name: 'product_detail', icon: 'category', label: 'Detay KPI' }, @@ -1646,6 +1666,7 @@ const performanceTabs = [ { name: 'order_market_details', icon: 'receipt_long', label: 'Piyasa > Müşteri > Ürün Sipariş' } ] const pageBusy = computed(() => loading.value) +const activeTableLoading = computed(() => loading.value || (backendGroupedSupportedTab(activeTab.value) && backendGroupedLoading.value)) const loadingElapsedLabel = computed(() => { if (!loadingStartedAt.value || !loadingNow.value) return '' const seconds = Math.max(0, Math.floor((loadingNow.value - loadingStartedAt.value) / 1000)) @@ -1737,7 +1758,7 @@ const detailSalesColumns = [ { name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true }, { name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true }, { name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true }, - { name: 'sales_qty', label: 'Adet', field: row => formatNumber(row.sales_qty, 0), align: 'right', 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 } ] @@ -2718,16 +2739,17 @@ const columnFilterOptionMap = computed(() => { }) const productTableRows = computed(() => { + if (shouldUseBackendGroupedRows('products')) return [] const out = [] appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels, 'products') return out }) -const detailProductTableRows = computed(() => buildGroupedTableRows('product_detail', filteredDetailProductRows.value)) +const detailProductTableRows = computed(() => shouldUseBackendGroupedRows('product_detail') ? [] : buildGroupedTableRows('product_detail', filteredDetailProductRows.value)) -const idleTableRows = computed(() => buildGroupedTableRows('idle', filteredIdleRows.value)) -const orderProductCustomerTableRows = computed(() => buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value)) -const orderMarketDetailTableRows = computed(() => buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value)) -const activeSalesBreakdownTableRows = computed(() => buildGroupedTableRows(activeTab.value, filteredActiveSalesBreakdownRows.value)) +const idleTableRows = computed(() => shouldUseBackendGroupedRows('idle') ? [] : buildGroupedTableRows('idle', filteredIdleRows.value)) +const orderProductCustomerTableRows = computed(() => shouldUseBackendGroupedRows('order_product_customers') ? [] : buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value)) +const orderMarketDetailTableRows = computed(() => shouldUseBackendGroupedRows('order_market_details') ? [] : buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value)) +const activeSalesBreakdownTableRows = computed(() => shouldUseBackendGroupedRows(activeTab.value) ? [] : buildGroupedTableRows(activeTab.value, filteredActiveSalesBreakdownRows.value)) const displayProductTableRows = computed(() => backendRowsForTab('products', productTableRows.value)) const displayDetailProductTableRows = computed(() => backendRowsForTab('product_detail', detailProductTableRows.value)) const displayProductKpiTableRows = computed(() => activeTab.value === 'product_detail' ? displayDetailProductTableRows.value : displayProductTableRows.value) @@ -2749,16 +2771,27 @@ const activeDisplayedTableRows = computed(() => { }) const detailMainGroupOptions = computed(() => { + if (detailMainGroupOptionRows.value.length) { + return [...detailMainGroupOptionRows.value] + .sort((a, b) => Number(b.sales_qty_90d || 0) - Number(a.sales_qty_90d || 0) || a.label.localeCompare(b.label, 'tr')) + } const grouped = new Map() for (const row of rows.value) { const value = productMainGroupValue(row) if (!value) continue - const current = grouped.get(value) || { value, label: value, stock_qty: 0, sales_qty_90d: 0 } - current.stock_qty += Number(row?.stock_qty || 0) + const current = grouped.get(value) || { value, label: value, stock_qty: 0, sales_qty_90d: 0, _stockSeen: new Set() } + const variantKey = productVariantMetricKey(row) + if (variantKey && !current._stockSeen.has(variantKey)) { + current._stockSeen.add(variantKey) + current.stock_qty += Number(row?.stock_qty || 0) + } else if (!variantKey) { + current.stock_qty += Number(row?.stock_qty || 0) + } current.sales_qty_90d += Number(row?.sales_qty_90d || 0) grouped.set(value, current) } return Array.from(grouped.values()) + .map(({ _stockSeen, ...option }) => option) .sort((a, b) => Number(b.sales_qty_90d || 0) - Number(a.sales_qty_90d || 0) || a.label.localeCompare(b.label, 'tr')) }) @@ -2809,7 +2842,6 @@ function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLeve function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, levels = groupLevels, tableKey = activeTab.value) { if (out.length >= maxRenderedGroupedRows) return if (level >= levels.length) { - out.push(...sortLeafRowsForTable(tableKey, sourceRows).slice(0, Math.max(0, maxRenderedGroupedRows - out.length))) return } @@ -2845,6 +2877,10 @@ function buildGroupedTableRows (tableKey, sourceRows) { } function backendRowsForTab (tabKey, fallbackRows) { + if (shouldUseBackendGroupedRows(tabKey)) { + const rows = backendGroupedRows.value[tabKey] + return Array.isArray(rows) ? rows : [] + } return tabKey === 'product_detail' ? filterRowsBySelectedDetailMainGroup(fallbackRows) : fallbackRows @@ -2854,19 +2890,34 @@ function filterSourceRowsForTab (tabKey, fallbackRows) { return fallbackRows } +function backendGroupedFilterState (tabKey) { + const out = {} + const columns = columnsForTableKey(tabKey) + for (const col of columns) { + const name = col?.name || '' + if (!isColumnFilterable(name)) continue + const selected = selectedColumnFilters(tabKey, name) + if (!selected.length) continue + if (!backendGroupedFilterFields.has(name)) { + continue + } + out[name] = selected.map(value => String(value || '').trim()).filter(Boolean) + } + return { filters: out, unsupported: false } +} + +function shouldUseBackendGroupedRows (tabKey) { + return backendGroupedSupportedTab(tabKey) && !backendGroupedFilterState(tabKey).unsupported +} + function tableFilterSource (tableKey) { - if (tableKey === 'products') return { rows: filterSourceRowsForTab('products', rows.value), columns: productColumns.value } - if (tableKey === 'product_detail') return { rows: filterSourceRowsForTab('product_detail', detailProductRows.value), columns: productDetailColumns.value } + if (backendGroupedSupportedTab(tableKey)) { + return { rows: backendGroupedRows.value[tableKey] || [], columns: columnsForTableKey(tableKey) } + } if (tableKey === 'general') return { rows: generalRows.value, columns: generalColumns } - if (tableKey === 'order_product_customers') return { rows: filterSourceRowsForTab('order_product_customers', orderProductCustomerRows.value), columns: orderProductCustomerColumns } - if (tableKey === 'order_market_details') return { rows: filterSourceRowsForTab('order_market_details', orderMarketDetailRows.value), columns: orderMarketDetailColumns } - if (tableKey === 'idle') return { rows: filterSourceRowsForTab('idle', idleRows.value), columns: idleColumns } if (tableKey === 'markets') return { rows: marketRows.value, columns: marketColumns } if (tableKey === 'countries') return { rows: countryRows.value, columns: countryColumns } if (tableKey === 'customers') return { rows: customerRows.value, columns: customerColumns } - if (salesBreakdownTabKeys.includes(tableKey)) { - return { rows: filterSourceRowsForTab(tableKey, salesBreakdownRows[tableKey] || []), columns: visibleSalesBreakdownColumns.value } - } return null } @@ -3143,6 +3194,7 @@ function aggregateGroupFields (sourceRows, groupField = '') { for (const field of fields) { if (field === 'row_key' || field === 'key') continue + if (/^gross_margin(_base|_cost)?(_|$)/i.test(field)) continue if (field === 'stock_qty') { out[field] = distinctVariantStockQty(sourceRows) continue @@ -3546,6 +3598,7 @@ function applyActiveExpansionThroughLevel (level) { setSelectedExpandLevelsThrough(level) replaceActiveTabExpandedGroups([]) lastManualExpandedGroupKey.value = '' + scheduleLoadBackendGroupedRows() } function toggleGroup (key) { @@ -3657,7 +3710,9 @@ function withProductMargins (row) { 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_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) } } @@ -3690,6 +3745,7 @@ function isColumnFilterable (name) { } function columnFilterOptions (tableKey, name) { + if (backendGroupedSupportedTab(tableKey) && !backendGroupedFilterFields.has(name)) return [] const options = columnFilterOptionMap.value[tableKey]?.[name] || [] const search = String(columnFilterSearch[filterKey(tableKey, name)] || '').trim().toLocaleLowerCase('tr') if (!search) return options @@ -3748,7 +3804,7 @@ function runWithFilterBusy (apply) { function syncExpansionAfterFilterChange () { if (!backendGroupedSupportedTab(activeTab.value)) return const level = selectedExpandThroughLevel.value - if (level >= 0) applyActiveExpansionThroughLevel(autoExpandThroughLevel.value) + applyActiveExpansionThroughLevel(level >= 0 ? autoExpandThroughLevel.value : -1) } function filterRowsForTable (tableKey, sourceRows, sourceColumns) { @@ -3822,7 +3878,13 @@ function productFirstGroupValue (row) { } function productMainGroupValue (row) { - return row?.urun_ana_grubu || '' + const direct = String(row?.urun_ana_grubu || '').trim() + if (direct) return direct + if (row?.__group && row.group_field === 'urun_ana_grubu') { + const groupValue = String(row.group_value || row.label || '').trim() + if (groupValue) return groupValue + } + return String(row?.urun_alt_grubu || row?.kategori || '').trim() } function cleanOptionalGroupValue (value) { @@ -4170,6 +4232,14 @@ function productImageKey (row) { return `${product}|${color}|${yaka}` } +function productVariantMetricKey (row) { + const product = String(row?.image_product_code || row?.product_code || '').trim() + if (!product) return '' + const color = String(row?.image_color_code || row?.color_code || '').trim() + const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim() + return `${product}|${color}|${yaka}` +} + function displayMarketName (value) { const parts = String(value || '').split('|').map(part => part.trim()).filter(Boolean) return parts.length ? parts[parts.length - 1] : '' @@ -4186,27 +4256,36 @@ function normalizeUploadsPath (storagePath) { } function resolveProductImageUrl (item) { - if (!item || typeof item !== 'object') return '' + return resolveProductImageUrls(item)[0] || '' +} - const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim() - if (thumbURL) return thumbURL - - const fullURL = String(item.full_url || item.fullUrl || '').trim() - if (fullURL) return fullURL - - const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '') - if (uploadsPath) return uploadsPath - - const fileName = String(item.file_name || item.FileName || '').trim() - if (fileName) return `/uploads/image/${fileName}` +function resolveProductImageUrls (item) { + if (!item || typeof item !== 'object') return [] + const urls = [] + const addURL = value => { + const url = String(value || '').trim() + if (url && !urls.includes(url)) urls.push(url) + } const contentURL = String(item.content_url || item.ContentURL || '').trim() - if (contentURL.startsWith('/api/')) return contentURL - if (contentURL.startsWith('/')) return `/api${contentURL}` + if (contentURL.startsWith('/api/')) addURL(contentURL) + else if (contentURL.startsWith('/')) addURL(`/api${contentURL}`) const imageId = Number(item.id || item.ID || 0) - if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content` - return '' + if (Number.isFinite(imageId) && imageId > 0) addURL(`/api/product-images/${imageId}/content`) + + const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim() + addURL(thumbURL) + + const fullURL = String(item.full_url || item.fullUrl || '').trim() + addURL(fullURL) + + const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '') + addURL(uploadsPath) + + const fileName = String(item.file_name || item.FileName || '').trim() + if (fileName) addURL(`/uploads/image/${fileName}`) + return urls } async function fetchProductImagesForRow (row) { @@ -4239,7 +4318,7 @@ async function fetchProductImagesForRow (row) { timeout: 60000 }) const list = Array.isArray(resp?.data) ? resp.data : [] - const urls = list.map(resolveProductImageUrl).filter(Boolean) + const urls = list.flatMap(resolveProductImageUrls).filter(Boolean) performanceStore.setImageCache(key, urls) if (urls.length && imageFailedKeys.value.has(key)) { const nextFailed = new Set(imageFailedKeys.value) @@ -4278,10 +4357,10 @@ function queueVisibleProductImage (row) { }) } -function markProductImageFailed (row) { +function markProductImageFailed (row, failedUrl = '') { const key = productImageKey(row) if (!key) return - const currentUrl = imageUrlByKey.value[key] || performanceStore.imageUrl(key) || '' + const currentUrl = String(failedUrl || imageUrlByKey.value[key] || performanceStore.imageUrl(key) || '').trim() const list = imageListByKey.value[key] || performanceStore.imageList(key) || [] const nextList = Array.isArray(list) ? list.filter(url => url && url !== currentUrl) : [] if (nextList.length) { @@ -4296,6 +4375,14 @@ function markProductImageFailed (row) { imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' } } +function markDialogImageFailed (url) { + const failedUrl = String(url || '').trim() + if (!failedUrl) return + imageDialogUrls.value = imageDialogUrls.value.filter(item => item && item !== failedUrl) + if (imageSlide.value >= imageDialogUrls.value.length) imageSlide.value = 0 + if (imageDialogRow.value) markProductImageFailed(imageDialogRow.value, failedUrl) +} + async function primeProductImages (sourceRows) { if (!isProductKpiTab.value) return imagePrimeActiveRequests += 1 @@ -4515,6 +4602,11 @@ function switchPerformanceTab (tabName) { async function loadBackendGroupedRows (options = {}) { const tabKey = activeTab.value if (!backendGroupedSupportedTab(tabKey)) return + const filterState = backendGroupedFilterState(tabKey) + if (filterState.unsupported) { + backendGroupedLoading.value = false + return + } if (tabKey === 'product_detail' && !ensureDetailMainGroupSelection()) { backendGroupedRows.value = { ...backendGroupedRows.value, @@ -4525,12 +4617,16 @@ async function loadBackendGroupedRows (options = {}) { } pruneActiveExpandedGroups() backendGroupedLoading.value = true - const requestKey = `${tabKey}|${activeExpandedGroupKeys().join('~')}|${selectedDetailMainGroup.value || ''}` + const filtersKey = JSON.stringify(filterState.filters) + const expandThroughLevel = selectedExpandThroughLevel.value + const requestKey = `${tabKey}|${expandThroughLevel}|${selectedDetailMainGroup.value || ''}|${filtersKey}` const params = { mode: tabKey, groupLevels: activeGroupLevels.value.map(level => level.key), expandedKeys: activeExpandedGroupKeys(), - limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000 + expandThroughLevel, + limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000, + filters: filterState.filters } if (tabKey === 'product_detail') { params.urunAnaGrubu = selectedDetailMainGroup.value @@ -4538,7 +4634,8 @@ async function loadBackendGroupedRows (options = {}) { try { const startedAt = performance.now() const rows = await performanceStore.fetchGroupedRows(params, { force: options.force === true }) - const currentKey = `${activeTab.value}|${activeExpandedGroupKeys().join('~')}|${selectedDetailMainGroup.value || ''}` + const currentFilterState = backendGroupedFilterState(activeTab.value) + const currentKey = `${activeTab.value}|${selectedExpandThroughLevel.value}|${selectedDetailMainGroup.value || ''}|${JSON.stringify(currentFilterState.filters)}` if (requestKey !== currentKey) return const normalizedRows = Array.isArray(rows) ? rows.map(row => normalizeGroupedDisplayRow(tabKey, row)) : [] backendGroupedRows.value = { @@ -4563,7 +4660,7 @@ function normalizeGroupedDisplayRow (tabKey, row) { if (tabKey === 'order_product_customers' || tabKey === 'order_market_details') { return withOrderPeriodScores(nextRow) } - const next = withPeriodScores(nextRow) + const next = withPeriodScores(withGeneralMargins(withProductMargins(nextRow))) if (tabKey !== 'products' && tabKey !== 'product_detail' && tabKey !== 'idle') { next.customer_score_90d = Number(next.customer_score_90d || customerSalesPeriodScore(periodMetricSource(next, '90d'))) next.customer_score_180d = Number(next.customer_score_180d || customerSalesPeriodScore(periodMetricSource(next, '180d'))) @@ -4620,6 +4717,8 @@ async function reload () { loading.value = true startLoadingTimer('Rapor verileri hazırlanıyor...') try { + rows.value = [] + detailMainGroupOptionRows.value = [] generalRowsLoaded.value = false generalRows.value = [] orderProductCustomersLoaded.value = false @@ -4639,22 +4738,15 @@ async function reload () { for (const key of salesBreakdownTabKeys) salesBreakdownRows[key] = [] loadingStage.value = 'Ana KPI ve özet yükleniyor...' - const [summaryResp, listResp] = await Promise.all([ + const [summaryResp] = await Promise.all([ timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 180000 }), - timedProductPerformanceGet('product-kpi', '/pricing/product-performance', { - params: { - page: 1, - limit: productKpiFetchLimit - }, - timeout: 180000 - }) + loadDetailMainGroupOptions(true) ]) summary.value = summaryResp?.data || {} - rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(normalizeRow) - loadingStage.value = 'Tüm rapor sekmeleri cacheleniyor...' - await preloadAllReportTabs() loadingStage.value = 'Rapor ekranı hazırlanıyor...' await nextTick() + ensureDetailMainGroupSelection() + applyActiveExpansionThroughLevel(autoExpandThroughLevel.value) } catch (err) { Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' }) } finally { @@ -4663,6 +4755,28 @@ async function reload () { } } +async function loadDetailMainGroupOptions (force = false) { + const optionRows = await performanceStore.fetchGroupedRows({ + mode: 'products', + groupLevels: ['urun_ana_grubu'], + expandThroughLevel: -1, + limit: productKpiFetchLimit + }, { force }) + detailMainGroupOptionRows.value = (Array.isArray(optionRows) ? optionRows : []) + .filter(row => row?.__group) + .map(row => { + const normalized = normalizeGroupedDisplayRow('products', row) + const value = productMainGroupValue(normalized) || String(normalized.group_value || normalized.label || '').trim() + return { + value, + label: value || '-', + stock_qty: Number(normalized.stock_qty || 0), + sales_qty_90d: Number(normalized.sales_qty_90d || 0) + } + }) + .filter(option => option.value) +} + function startLoadingTimer (stage = '') { loadingStage.value = stage loadingStartedAt.value = Date.now() @@ -4781,7 +4895,7 @@ async function loadMarketRows (showLoading = true) { if (showLoading) loading.value = true try { const resp = await timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: reportFetchLimit }, timeout: 180000 }) - marketRows.value = Array.isArray(resp?.data) ? resp.data : [] + marketRows.value = aggregateMarketRowsByDisplayName(Array.isArray(resp?.data) ? resp.data : []) marketRowsLoaded.value = true } catch (err) { Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Piyasa performans verisi alınamadı' }) @@ -4790,6 +4904,60 @@ async function loadMarketRows (showLoading = true) { } } +function aggregateMarketRowsByDisplayName (sourceRows) { + const grouped = new Map() + for (const row of sourceRows || []) { + const display = displayMarketName(row?.market_key) || String(row?.market_key || '').trim() || '-' + const current = grouped.get(display) || { + ...row, + row_key: `market|${display}`, + market_key: display, + product_count: 0, + star_count: 0, + stock_risk_count: 0, + stock_qty: 0, + stock_cost_value_usd: 0, + risk_stock_cost_value_usd: 0, + sales_qty_90d: 0, + sales_usd_90d: 0, + gross_profit_usd_90d: 0, + _margin_weight: 0, + _margin_total: 0, + _stock_days_weight: 0, + _stock_days_total: 0 + } + current.product_count += Number(row?.product_count || 0) + current.star_count += Number(row?.star_count || 0) + current.stock_risk_count += Number(row?.stock_risk_count || 0) + current.stock_qty += Number(row?.stock_qty || 0) + current.stock_cost_value_usd += Number(row?.stock_cost_value_usd || 0) + current.risk_stock_cost_value_usd += Number(row?.risk_stock_cost_value_usd || 0) + current.sales_qty_90d += Number(row?.sales_qty_90d || 0) + current.sales_usd_90d += Number(row?.sales_usd_90d || 0) + current.gross_profit_usd_90d += Number(row?.gross_profit_usd_90d || 0) + const marginWeight = Math.abs(Number(row?.sales_usd_90d || 0)) + current._margin_weight += marginWeight + current._margin_total += Number(row?.avg_gross_margin_90d || 0) * marginWeight + const stockDaysWeight = Math.abs(Number(row?.stock_qty || 0)) + current._stock_days_weight += stockDaysWeight + current._stock_days_total += Number(row?.avg_stock_days_90d || 0) * stockDaysWeight + grouped.set(display, current) + } + return Array.from(grouped.values()) + .map(row => { + row.avg_gross_margin_90d = row._margin_weight > 0 ? row._margin_total / row._margin_weight : 0 + row.avg_stock_days_90d = row._stock_days_weight > 0 ? row._stock_days_total / row._stock_days_weight : 0 + delete row._margin_weight + delete row._margin_total + delete row._stock_days_weight + delete row._stock_days_total + return row + }) + .sort((a, b) => Number(b.risk_stock_cost_value_usd || 0) - Number(a.risk_stock_cost_value_usd || 0) || + Number(b.stock_cost_value_usd || 0) - Number(a.stock_cost_value_usd || 0) || + Number(b.sales_usd_90d || 0) - Number(a.sales_usd_90d || 0)) +} + async function loadCountryRows (showLoading = true) { if (countryRowsLoaded.value) return if (showLoading) loading.value = true @@ -4833,9 +5001,9 @@ async function ensureActiveTabData (showLoading = true) { if (tab === 'markets') return loadMarketRows(showLoading) if (tab === 'countries') return loadCountryRows(showLoading) if (tab === 'customers') return loadCustomerRows(showLoading) - if (tab === 'order_product_customers') return loadOrderProductCustomers(showLoading) - if (tab === 'order_market_details') return loadOrderMarketDetails(showLoading) - if (salesBreakdownTabKeys.includes(tab)) return loadSalesBreakdownRows(tab, showLoading) + if (backendGroupedSupportedTab(tab)) { + scheduleLoadBackendGroupedRows() + } } async function loadOrderAnalysis (showLoading = true) { @@ -4874,7 +5042,9 @@ function bucketColor (value) { } watch(activeTab, () => { - void ensureActiveTabData(false) + void ensureActiveTabData(false).then(() => { + if (backendGroupedSupportedTab(activeTab.value)) applyActiveExpansionThroughLevel(autoExpandThroughLevel.value) + }) setupTopScrollbarSync() }) @@ -5263,95 +5433,95 @@ onBeforeUnmount(() => { table-layout: fixed; } -.product-breakdown-table :deep(.q-table th:nth-child(-n+10)), -.product-breakdown-table :deep(.q-table td:nth-child(-n+10)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(-n+10)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(-n+10)) { position: sticky; z-index: 2; } -.product-breakdown-table :deep(.q-table th:nth-child(-n+10)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(-n+10)) { z-index: 30; background: #f8fbff; } -.product-breakdown-table :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) { background: #fff; } -.product-breakdown-table :deep(.q-table th:nth-child(1)), -.product-breakdown-table :deep(.q-table td:nth-child(1)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(1)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(1)) { left: 0; width: 190px; min-width: 190px; max-width: 190px; } -.product-breakdown-table :deep(.q-table th:nth-child(2)), -.product-breakdown-table :deep(.q-table td:nth-child(2)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(2)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(2)) { left: 190px; width: 140px; min-width: 140px; max-width: 140px; } -.product-breakdown-table :deep(.q-table th:nth-child(3)), -.product-breakdown-table :deep(.q-table td:nth-child(3)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(3)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(3)) { left: 330px; width: 110px; min-width: 110px; max-width: 110px; } -.product-breakdown-table :deep(.q-table th:nth-child(4)), -.product-breakdown-table :deep(.q-table td:nth-child(4)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(4)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(4)) { left: 440px; width: 150px; min-width: 150px; max-width: 150px; } -.product-breakdown-table :deep(.q-table th:nth-child(5)), -.product-breakdown-table :deep(.q-table td:nth-child(5)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(5)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(5)) { left: 590px; width: 170px; min-width: 170px; max-width: 170px; } -.product-breakdown-table :deep(.q-table th:nth-child(6)), -.product-breakdown-table :deep(.q-table td:nth-child(6)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(6)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(6)) { left: 760px; width: 150px; min-width: 150px; max-width: 150px; } -.product-breakdown-table :deep(.q-table th:nth-child(7)), -.product-breakdown-table :deep(.q-table td:nth-child(7)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(7)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(7)) { left: 910px; width: 130px; min-width: 130px; max-width: 130px; } -.product-breakdown-table :deep(.q-table th:nth-child(8)), -.product-breakdown-table :deep(.q-table td:nth-child(8)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(8)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(8)) { left: 1040px; width: 90px; min-width: 90px; max-width: 90px; } -.product-breakdown-table :deep(.q-table th:nth-child(9)), -.product-breakdown-table :deep(.q-table td:nth-child(9)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(9)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(9)) { left: 1130px; width: 80px; min-width: 80px; max-width: 80px; } -.product-breakdown-table :deep(.q-table th:nth-child(10)), -.product-breakdown-table :deep(.q-table td:nth-child(10)) { +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(10)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(10)) { left: 1210px; width: 130px; min-width: 130px; @@ -5475,8 +5645,8 @@ onBeforeUnmount(() => { font-variant-numeric: tabular-nums; } -.product-breakdown-table :deep(.q-table th:nth-child(n+11):not(.text-right)), -.product-breakdown-table :deep(.q-table td:nth-child(n+11):not(.text-right)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(n+11):not(.text-right)), +.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(n+11):not(.text-right)), .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(n+4):not(.text-right)), .sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(n+4):not(.text-right)), .sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(n+6):not(.text-right)), diff --git a/ui/src/stores/productPerformanceStore.js b/ui/src/stores/productPerformanceStore.js index ca942e5..7f2e12b 100644 --- a/ui/src/stores/productPerformanceStore.js +++ b/ui/src/stores/productPerformanceStore.js @@ -15,27 +15,46 @@ function normalizeUploadsPath (storagePath) { } function resolveProductImageUrl (item) { - if (!item || typeof item !== 'object') return '' + return resolveProductImageUrls(item)[0] || '' +} - const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim() - if (thumbURL) return thumbURL - - const fullURL = String(item.full_url || item.fullUrl || '').trim() - if (fullURL) return fullURL - - const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '') - if (uploadsPath) return uploadsPath - - const fileName = String(item.file_name || item.FileName || '').trim() - if (fileName) return `/uploads/image/${fileName}` +function resolveProductImageUrls (item) { + if (!item || typeof item !== 'object') return [] + const urls = [] + const addURL = value => { + const url = String(value || '').trim() + if (url && !urls.includes(url)) urls.push(url) + } const contentURL = String(item.content_url || item.ContentURL || '').trim() - if (contentURL.startsWith('/api/')) return contentURL - if (contentURL.startsWith('/')) return `/api${contentURL}` + if (contentURL.startsWith('/api/')) addURL(contentURL) + else if (contentURL.startsWith('/')) addURL(`/api${contentURL}`) const imageId = Number(item.id || item.ID || 0) - if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content` - return '' + if (Number.isFinite(imageId) && imageId > 0) addURL(`/api/product-images/${imageId}/content`) + + const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim() + addURL(thumbURL) + + const fullURL = String(item.full_url || item.fullUrl || '').trim() + addURL(fullURL) + + const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '') + addURL(uploadsPath) + + const fileName = String(item.file_name || item.FileName || '').trim() + if (fileName) addURL(`/uploads/image/${fileName}`) + return urls +} + +function normalizedFilterMap (filters = {}) { + return Object.fromEntries(Object.entries(filters || {}) + .map(([key, values]) => [ + String(key || '').trim(), + normalizedList(values, true) + ]) + .filter(([key, values]) => key && values.length) + .sort((a, b) => a[0].localeCompare(b[0], 'tr'))) } function normalizedList (value, sort = false) { @@ -69,6 +88,8 @@ export const useProductPerformanceStore = defineStore('product-performance-store mainGroup: String(params.urunAnaGrubu || params.urun_ana_grubu || ''), groupLevels: normalizedList(params.groupLevels), expandedKeys: normalizedList(params.expandedKeys, true), + expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1), + filters: normalizedFilterMap(params.filters), limit: Number(params.limit || 0) }) }, @@ -91,7 +112,8 @@ export const useProductPerformanceStore = defineStore('product-performance-store console.info(`${logPrefix} in-flight reuse`, { mode: params.mode, groupLevels: normalizedList(params.groupLevels), - expandedKeys: normalizedList(params.expandedKeys, true).length + expandedKeys: normalizedList(params.expandedKeys, true).length, + expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1) }) return this.groupedInFlightByKey[key] } @@ -102,6 +124,7 @@ export const useProductPerformanceStore = defineStore('product-performance-store mode: params.mode, groupLevels: normalizedList(params.groupLevels), expandedKeys: normalizedList(params.expandedKeys, true).length, + expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1), limit: params.limit }) const request = api.post('/pricing/product-performance/grouped', { @@ -109,12 +132,15 @@ export const useProductPerformanceStore = defineStore('product-performance-store urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '', group_levels: Array.isArray(params.groupLevels) ? params.groupLevels : String(params.groupLevels || '').split(',').filter(Boolean), expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys : String(params.expandedKeys || '').split(',').filter(Boolean), + expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1), + filters: normalizedFilterMap(params.filters), limit: params.limit }, { params: { mode: params.mode, limit: params.limit, - urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '' + urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '', + expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1) }, timeout: 90000 }).then(resp => { @@ -190,7 +216,7 @@ export const useProductPerformanceStore = defineStore('product-performance-store const key = String(item?.key || '').trim() if (!key) continue returned.add(key) - const urls = (Array.isArray(item.images) ? item.images : []).map(resolveProductImageUrl).filter(Boolean) + const urls = (Array.isArray(item.images) ? item.images : []).flatMap(resolveProductImageUrls).filter(Boolean) this.setImageCache(key, urls) lists[key] = urls urlsByKey[key] = urls[0] || ''