From e7a4c589348965a1bef639a54fa56b9333b48aa8 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Fri, 3 Jul 2026 02:24:24 +0300 Subject: [PATCH] product performance grouped kpi improvements --- svc/queries/product_performance.go | 102 +++++++++++++- .../pages/ProductPerformanceProfitability.vue | 129 ++++++++++++++---- 2 files changed, 198 insertions(+), 33 deletions(-) diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index 8cf2ddd..bbd7830 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -2910,7 +2910,7 @@ FROM ( COALESCE((ARRAY_AGG(NULLIF(urun_ana_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ana_grubu, COALESCE((ARRAY_AGG(NULLIF(urun_alt_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_alt_grubu, COALESCE((ARRAY_AGG(NULLIF(market_key,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS market_key, - COALESCE(SUM(stock_qty),0) AS stock_qty, + COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) AS stock_qty, COALESCE(SUM(sales_qty_90d),0) AS sales_qty_90d, COALESCE(SUM(sales_qty_180d),0) AS sales_qty_180d, COALESCE(SUM(sales_qty_365d),0) AS sales_qty_365d, @@ -2919,9 +2919,9 @@ FROM ( COALESCE(SUM(sales_usd_365d),0) AS sales_usd_365d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_usd_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS avg_price_usd_90d, CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_usd_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS avg_price_usd_180d, - CASE WHEN SUM(stock_qty) > 0 THEN SUM(cost_price_usd * stock_qty) / NULLIF(SUM(stock_qty),0) ELSE AVG(cost_price_usd) END AS cost_price_usd, - CASE WHEN SUM(stock_qty) > 0 THEN SUM(base_price_usd * stock_qty) / NULLIF(SUM(stock_qty),0) ELSE AVG(base_price_usd) END AS base_price_usd, - CASE WHEN SUM(stock_qty) > 0 THEN SUM(base_price_try * stock_qty) / NULLIF(SUM(stock_qty),0) ELSE AVG(base_price_try) END AS base_price_try, + CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN cost_price_usd * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE AVG(cost_price_usd) END AS cost_price_usd, + CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN base_price_usd * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE AVG(base_price_usd) END AS base_price_usd, + CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN base_price_try * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE AVG(base_price_try) END AS base_price_try, COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d, COALESCE(SUM(gross_profit_usd_180d),0) AS gross_profit_usd_180d, CASE WHEN SUM(sales_usd_90d) > 0 THEN SUM(gross_profit_usd_90d) / NULLIF(SUM(sales_usd_90d),0) ELSE 0 END AS gross_margin_90d, @@ -2937,13 +2937,21 @@ FROM ( CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(margin_index_90d) END AS margin_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(performance_score * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(performance_score) END AS performance_score, MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket, - COALESCE(SUM(idle_cost_usd),0) AS idle_cost_usd - FROM Source + COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN idle_cost_usd ELSE 0 END),0) AS idle_cost_usd + FROM ( + SELECT + Source.*, + ROW_NUMBER() OVER ( + PARTITION BY COALESCE(NULLIF(%s,''), '-'), product_code, color_code, yaka_kodu + ORDER BY performance_score DESC NULLS LAST + ) AS stock_variant_rank + FROM Source %s + ) s GROUP BY COALESCE(NULLIF(%s,''), '-') ) g ORDER BY group_value -`, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, groupExpr, whereSQL, groupExpr) +`, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, groupExpr, groupExpr, whereSQL, groupExpr) args = append(args, field) return queryProductPerformanceJSONRows(ctx, pg, query, args...) } @@ -3192,6 +3200,14 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m if key == "row_key" || key == "key" { continue } + if key == "stock_qty" { + out[key] = distinctProductPerformanceVariantStockQty(rows) + continue + } + if key == "idle_cost_usd" { + out[key] = distinctProductPerformanceVariantStockCost(rows) + continue + } if shouldSumProductPerformanceField(key) { out[key] = floatFromAny(out[key]) + floatFromAny(value) } else if _, ok := out[key]; !ok && !shouldAverageProductPerformanceField(key) { @@ -3211,6 +3227,75 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m return out } +func distinctProductPerformanceVariantStockQty(rows []map[string]any) float64 { + seen := map[string]bool{} + total := 0.0 + hasKey := false + for _, row := range rows { + key := productPerformanceMapVariantKey(row) + if key == "" { + continue + } + hasKey = true + if seen[key] { + continue + } + seen[key] = true + total += floatFromMap(row, "stock_qty") + } + if hasKey { + return total + } + for _, row := range rows { + total += floatFromMap(row, "stock_qty") + } + return total +} + +func distinctProductPerformanceVariantStockCost(rows []map[string]any) float64 { + seen := map[string]bool{} + total := 0.0 + hasKey := false + for _, row := range rows { + key := productPerformanceMapVariantKey(row) + if key == "" { + continue + } + hasKey = true + if seen[key] { + continue + } + seen[key] = true + total += floatFromMap(row, "stock_qty") * floatFromMap(row, "cost_price_usd") + } + if hasKey { + return total + } + for _, row := range rows { + total += floatFromMap(row, "idle_cost_usd") + } + return total +} + +func productPerformanceMapVariantKey(row map[string]any) string { + productCode := stringFromMap(row, "product_code") + if productCode == "" { + productCode = stringFromMap(row, "image_product_code") + } + colorCode := stringFromMap(row, "color_code") + if colorCode == "" { + colorCode = stringFromMap(row, "image_color_code") + } + yakaKodu := stringFromMap(row, "yaka_kodu") + if yakaKodu == "" { + yakaKodu = stringFromMap(row, "image_yaka_kodu") + } + if productCode == "" && colorCode == "" && yakaKodu == "" { + return "" + } + return productPerformanceVariantKey(productCode, colorCode, yakaKodu) +} + func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) { for _, suffix := range []string{"90d", "180d", "365d", "total"} { sales := floatFromMap(out, "sales_usd_"+suffix) @@ -3243,6 +3328,9 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) if orderQty > 0 { out["avg_order_price_usd"] = orderUSD / orderQty } + if _, ok := out["net_stock_after_order"]; ok || orderQty > 0 { + out["net_stock_after_order"] = floatFromMap(out, "stock_qty") - orderQty + } out["customer_score_90d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "90d")) out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d")) out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d")) diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 8f61b26..6eeadfd 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -176,6 +176,7 @@ v-if="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)" class="product-thumb" + @error="markProductImageFailed(props.row)" loading="lazy" decoding="async" alt="" @@ -286,6 +287,7 @@ v-if="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)" class="product-thumb" + @error="markProductImageFailed(props.row)" loading="lazy" decoding="async" alt="" @@ -327,6 +329,7 @@ v-if="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)" class="product-thumb" + @error="markProductImageFailed(props.row)" loading="lazy" decoding="async" alt="" @@ -363,6 +366,7 @@ v-if="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)" class="product-thumb" + @error="markProductImageFailed(props.row)" loading="lazy" decoding="async" alt="" @@ -616,6 +620,7 @@ v-if="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)" class="product-thumb" + @error="markProductImageFailed(props.row)" loading="lazy" decoding="async" alt="" @@ -652,6 +657,7 @@ v-if="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)" class="product-thumb" + @error="markProductImageFailed(props.row)" loading="lazy" decoding="async" alt="" @@ -761,6 +767,7 @@ v-if="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)" class="product-thumb" + @error="markProductImageFailed(props.row)" loading="lazy" decoding="async" alt="" @@ -798,6 +805,7 @@ v-if="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)" class="product-thumb" + @error="markProductImageFailed(props.row)" loading="lazy" decoding="async" alt="" @@ -834,6 +842,7 @@ v-if="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)" class="product-thumb" + @error="markProductImageFailed(props.row)" loading="lazy" decoding="async" alt="" @@ -2477,6 +2486,14 @@ function aggregateGroupFields (sourceRows, groupField = '') { for (const field of fields) { if (field === 'row_key' || field === 'key') continue + if (field === 'stock_qty') { + out[field] = distinctVariantStockQty(sourceRows) + continue + } + if (field === 'idle_cost_usd') { + out[field] = distinctVariantStockCost(sourceRows) + continue + } if (shouldSumField(field)) { out[field] = sumRows(sourceRows, field) } else if (shouldAverageField(field)) { @@ -2532,6 +2549,9 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { const orderQty = Number(out.order_qty || 0) if (orderQty > 0) out.avg_order_price_usd = Number(out.order_usd || 0) / orderQty + if (out.net_stock_after_order !== undefined || orderQty > 0) { + out.net_stock_after_order = Number(out.stock_qty || 0) - orderQty + } if (sourceRows.some(row => row.performance_bucket)) { out.performance_bucket = dominantValue(sourceRows, 'performance_bucket') @@ -2618,6 +2638,47 @@ function sumRows (sourceRows, field) { return sourceRows.reduce((sum, row) => sum + Number(row[field] || 0), 0) } +function productVariantStockKey (row) { + const productCode = String(row?.product_code || row?.image_product_code || '').trim() + const colorCode = String(row?.color_code || row?.image_color_code || '').trim() + const yakaKodu = String(row?.yaka_kodu || row?.image_yaka_kodu || '').trim() + if (!productCode && !colorCode && !yakaKodu) return '' + return `${productCode}|${colorCode}|${yakaKodu}` +} + +function distinctVariantStockQty (sourceRows) { + const seen = new Set() + let total = 0 + let keyed = false + for (const row of sourceRows) { + const key = productVariantStockKey(row) + const stockQty = Number(row?.stock_qty || 0) + if (!key) continue + keyed = true + if (seen.has(key)) continue + seen.add(key) + total += stockQty + } + return keyed ? total : sumRows(sourceRows, 'stock_qty') +} + +function distinctVariantStockCost (sourceRows) { + const seen = new Set() + let total = 0 + let keyed = false + for (const row of sourceRows) { + const key = productVariantStockKey(row) + if (!key) continue + keyed = true + if (seen.has(key)) continue + seen.add(key) + const stockQty = Number(row?.stock_qty || 0) + const costPrice = Number(row?.cost_price_usd || 0) + total += stockQty * costPrice + } + return keyed ? total : sumRows(sourceRows, 'idle_cost_usd') +} + function distinctCount (sourceRows, field) { const values = new Set() for (const row of sourceRows) { @@ -3234,6 +3295,14 @@ function getCachedProductImageUrl (row) { return imageUrlByKey.value[key] || performanceStore.imageUrl(key) || '' } +function markProductImageFailed (row) { + const key = productImageKey(row) + if (!key) return + performanceStore.setImageCache(key, []) + imageListByKey.value = { ...imageListByKey.value, [key]: [] } + imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' } +} + async function primeProductImages (sourceRows) { imagePrimeActiveRequests += 1 imagePrimeLoading.value = true @@ -4166,48 +4235,56 @@ onMounted(reload) text-align: center; } -.group-row td { - background: color-mix(in srgb, var(--q-primary) 8%, #ffffff); +.performance-table :deep(.q-table tbody tr.group-row td) { + background: #edf3fb; font-weight: 600; - color: color-mix(in srgb, var(--q-primary) 55%, #1f2937); - border-color: color-mix(in srgb, var(--q-primary) 18%, #d5dce5); + color: #1f2937; + border-color: #c4d2e2; white-space: nowrap; } -.group-row-level-0 td { - background: color-mix(in srgb, var(--q-primary) 82%, #1f2937); +.performance-table :deep(.q-table tbody tr.group-row.group-row-level-0 td) { + background: #2f5f8f; color: #fff; - border-color: color-mix(in srgb, var(--q-primary) 75%, #111827); + border-color: #244b72; } -.group-row-level-1 td { - background: color-mix(in srgb, var(--q-primary) 68%, #ffffff); +.performance-table :deep(.q-table tbody tr.group-row.group-row-level-1 td) { + background: #4f7fad; color: #fff; - border-color: color-mix(in srgb, var(--q-primary) 58%, #d5dce5); + border-color: #426d96; } -.group-row-level-2 td { - background: color-mix(in srgb, var(--q-primary) 36%, #ffffff); - color: color-mix(in srgb, var(--q-primary) 65%, #111827); - border-color: color-mix(in srgb, var(--q-primary) 34%, #d5dce5); +.performance-table :deep(.q-table tbody tr.group-row.group-row-level-2 td) { + background: #d8e6f4; + color: #1f2937; + border-color: #b8cce0; } -.group-row-level-3 td { - background: color-mix(in srgb, var(--q-primary) 22%, #ffffff); - color: color-mix(in srgb, var(--q-primary) 58%, #1f2937); - border-color: color-mix(in srgb, var(--q-primary) 24%, #d5dce5); +.performance-table :deep(.q-table tbody tr.group-row.group-row-level-3 td) { + background: #e6eef7; + color: #1f2937; + border-color: #c4d2e2; } -.group-row-level-4 td { - background: color-mix(in srgb, var(--q-primary) 14%, #ffffff); - color: color-mix(in srgb, var(--q-primary) 52%, #1f2937); - border-color: color-mix(in srgb, var(--q-primary) 18%, #d5dce5); +.performance-table :deep(.q-table tbody tr.group-row.group-row-level-4 td) { + background: #eef4fa; + color: #1f2937; + border-color: #cbd8e6; } -.group-row-level-5 td { - background: color-mix(in srgb, var(--q-primary) 8%, #ffffff); - color: color-mix(in srgb, var(--q-primary) 48%, #1f2937); - border-color: color-mix(in srgb, var(--q-primary) 14%, #d5dce5); +.performance-table :deep(.q-table tbody tr.group-row.group-row-level-5 td) { + background: #f4f8fc; + color: #1f2937; + border-color: #d5dce5; +} + +.performance-table :deep(.q-table tbody tr.group-row td span), +.performance-table :deep(.q-table tbody tr.group-row .group-title), +.performance-table :deep(.q-table tbody tr.group-row .q-btn), +.performance-table :deep(.q-table tbody tr.group-row .q-icon) { + color: inherit; + opacity: 1; } .group-cell-label {