ui: update ProductPerformanceProfitability page to enhance table views and tabs management

This commit is contained in:
M_Kececi
2026-07-03 00:30:54 +03:00
parent c536b85a4c
commit be6feb8aa8
3 changed files with 710 additions and 71 deletions
+300
View File
@@ -2600,6 +2600,10 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
levels = defaultProductPerformanceGroupLevels(req.Mode) levels = defaultProductPerformanceGroupLevels(req.Mode)
} }
if out, ok, err := listProductPerformanceGroupedSQL(ctx, pg, req, levels); ok || err != nil {
return out, err
}
sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit) sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -2609,6 +2613,302 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
return out, nil return out, nil
} }
type productPerformanceSQLGroupFilter struct {
Field string
Value string
}
func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) {
mode := strings.TrimSpace(req.Mode)
if mode != "products" && mode != "idle" {
return nil, false, nil
}
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, true, err
}
out := make([]map[string]any, 0, 512)
err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, []string{"tab:" + mode}, nil, req.ExpandedKeys, req.Limit)
return out, true, err
}
func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, 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
}
field := levels[level]
rows, err := queryProductPerformanceSQLGroupRows(ctx, pg, mode, field, level, parentKeys, filters)
if err != nil {
return err
}
for _, row := range rows {
value := stringFromMap(row, "group_value")
keyPart := field + ":" + value
key := strings.Join(append(parentKeys, keyPart), "|")
row["row_key"] = "group|" + key
row["key"] = key
row["level"] = level
row["group_field"] = field
row["group_value"] = value
row["label"] = value
row["__group"] = true
row[field] = value
*out = append(*out, row)
if expandedKeys[key] {
nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value})
if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, append(parentKeys, keyPart), nextFilters, expandedKeys, limit); err != nil {
return err
}
}
}
return nil
}
func queryProductPerformanceSQLGroupRows(ctx context.Context, pg *sql.DB, mode, field string, level int, parentKeys []string, filters []productPerformanceSQLGroupFilter) ([]map[string]any, error) {
groupExpr, ok := productPerformanceSQLGroupExpr(field)
if !ok {
return nil, fmt.Errorf("unsupported product performance group field: %s", field)
}
whereSQL, args, err := productPerformanceSQLFilterWhere(filters)
if err != nil {
return nil, err
}
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
SELECT jsonb_build_object(
'group_value', group_value,
'label', group_value,
'count', row_count,
'recommendation', row_count::text || ' satir',
'image_product_code', image_product_code,
'image_color_code', image_color_code,
'image_yaka_kodu', image_yaka_kodu,
'product_code', CASE WHEN $%d = 'product_code' THEN group_value ELSE product_code END,
'color_code', CASE WHEN $%d = 'color_code' THEN group_value ELSE color_code END,
'yaka_kodu', CASE WHEN $%d = 'yaka_kodu' THEN group_value ELSE yaka_kodu END,
'item_description', item_description,
'kategori', CASE WHEN $%d = 'kategori' THEN group_value ELSE kategori END,
'askili_yan', CASE WHEN $%d = 'askili_yan' THEN group_value ELSE askili_yan END,
'urun_ilk_grubu', CASE WHEN $%d = 'urun_ilk_grubu' THEN group_value ELSE urun_ilk_grubu END,
'urun_ana_grubu', CASE WHEN $%d = 'urun_ana_grubu' THEN group_value ELSE urun_ana_grubu END,
'urun_alt_grubu', CASE WHEN $%d = 'urun_alt_grubu' THEN group_value ELSE urun_alt_grubu END,
'market_key', CASE WHEN $%d = 'market_key' THEN group_value ELSE market_key END,
'stock_qty', stock_qty,
'sales_qty_90d', sales_qty_90d,
'sales_qty_180d', sales_qty_180d,
'sales_qty_365d', sales_qty_365d,
'sales_usd_90d', sales_usd_90d,
'sales_usd_180d', sales_usd_180d,
'sales_usd_365d', sales_usd_365d,
'avg_price_usd_90d', avg_price_usd_90d,
'avg_price_usd_180d', avg_price_usd_180d,
'cost_price_usd', cost_price_usd,
'base_price_usd', base_price_usd,
'base_price_try', base_price_try,
'gross_profit_usd_90d', gross_profit_usd_90d,
'gross_profit_usd_180d', gross_profit_usd_180d,
'gross_margin_90d', gross_margin_90d,
'gross_margin_180d', gross_margin_180d,
'unit_profit_cost_90d', unit_profit_cost_90d,
'unit_profit_cost_180d', unit_profit_cost_180d,
'unit_profit_base_90d', unit_profit_base_90d,
'unit_profit_base_180d', unit_profit_base_180d,
'market_count_90d', market_count_90d,
'customer_count_90d', customer_count_90d,
'sales_index_90d', sales_index_90d,
'price_index_90d', price_index_90d,
'margin_index_90d', margin_index_90d,
'performance_score', performance_score,
'performance_bucket', performance_bucket,
'idle_cost_usd', idle_cost_usd,
'stock_turnover_90d', CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END,
'stock_turnover_180d', CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END,
'stock_turnover_365d', CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END
) AS row_json
FROM (
SELECT
COALESCE(NULLIF(%s,''), '-') AS group_value,
COUNT(*)::integer AS row_count,
(ARRAY_AGG(product_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_product_code,
(ARRAY_AGG(color_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_color_code,
(ARRAY_AGG(yaka_kodu ORDER BY performance_score DESC NULLS LAST))[1] AS image_yaka_kodu,
COALESCE((ARRAY_AGG(NULLIF(product_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS product_code,
COALESCE((ARRAY_AGG(NULLIF(color_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS color_code,
COALESCE((ARRAY_AGG(NULLIF(yaka_kodu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS yaka_kodu,
COALESCE((ARRAY_AGG(NULLIF(item_description,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS item_description,
COALESCE((ARRAY_AGG(NULLIF(kategori,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS kategori,
COALESCE((ARRAY_AGG(NULLIF(askili_yan,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS askili_yan,
COALESCE((ARRAY_AGG(NULLIF(urun_ilk_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ilk_grubu,
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(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,
COALESCE(SUM(sales_usd_90d),0) AS sales_usd_90d,
COALESCE(SUM(sales_usd_180d),0) AS sales_usd_180d,
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,
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,
CASE WHEN SUM(sales_usd_180d) > 0 THEN SUM(gross_profit_usd_180d) / NULLIF(SUM(sales_usd_180d),0) ELSE 0 END AS gross_margin_180d,
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_cost_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(unit_profit_cost_90d) END AS unit_profit_cost_90d,
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_cost_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE AVG(unit_profit_cost_180d) END AS unit_profit_cost_180d,
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_base_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(unit_profit_base_90d) END AS unit_profit_base_90d,
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_base_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE AVG(unit_profit_base_180d) END AS unit_profit_base_180d,
COALESCE(SUM(market_count_90d),0)::integer AS market_count_90d,
COALESCE(SUM(customer_count_90d),0)::integer AS customer_count_90d,
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(sales_index_90d) END AS sales_index_90d,
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(price_index_90d) END AS price_index_90d,
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
%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)
args = append(args, field)
return queryProductPerformanceJSONRows(ctx, pg, query, args...)
}
func queryProductPerformanceSQLLeafRows(ctx context.Context, pg *sql.DB, mode string, filters []productPerformanceSQLGroupFilter, limit int) ([]map[string]any, error) {
whereSQL, args, err := productPerformanceSQLFilterWhere(filters)
if err != nil {
return nil, err
}
args = append(args, limit)
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
SELECT to_jsonb(t) || jsonb_build_object(
'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key,
'stock_turnover_90d', CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END,
'stock_turnover_180d', CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END,
'stock_turnover_365d', CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END
) AS row_json
FROM Source t
%s
ORDER BY performance_score DESC, sales_usd_90d DESC
LIMIT $%d
`, whereSQL, len(args))
return queryProductPerformanceJSONRows(ctx, pg, query, args...)
}
func queryProductPerformanceJSONRows(ctx context.Context, pg *sql.DB, query string, args ...any) ([]map[string]any, error) {
rows, err := pg.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return nil, err
}
row := map[string]any{}
if err := json.Unmarshal(raw, &row); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func productPerformanceSQLSourceCTE(mode string) string {
idleWhere := ""
if mode == "idle" {
idleWhere = `
AND stock_qty > 0
AND (performance_bucket = 'STOK_RISKI' OR COALESCE(sales_qty_90d,0) = 0 OR COALESCE(stock_days_90d,0) > 180)`
}
return `
WITH Source AS (
SELECT
product_code,
color_code,
yaka_kodu,
item_description,
kategori,
seri,
yas_grubu,
askili_yan,
COALESCE(NULLIF(urun_ilk_grubu,''), yas_grubu) AS urun_ilk_grubu,
COALESCE(NULLIF(urun_ana_grubu,''), seri) AS urun_ana_grubu,
urun_alt_grubu,
market_key,
COALESCE(stock_qty,0) AS stock_qty,
COALESCE(sales_qty_90d,0) AS sales_qty_90d,
COALESCE(sales_qty_180d,0) AS sales_qty_180d,
COALESCE(sales_qty_365d,0) AS sales_qty_365d,
COALESCE(sales_usd_90d,0) AS sales_usd_90d,
COALESCE(sales_usd_180d,0) AS sales_usd_180d,
COALESCE(sales_usd_365d,0) AS sales_usd_365d,
COALESCE(avg_price_usd_90d,0) AS avg_price_usd_90d,
COALESCE(avg_price_usd_180d,0) AS avg_price_usd_180d,
COALESCE(cost_price_usd,0) AS cost_price_usd,
COALESCE(base_price_usd,0) AS base_price_usd,
COALESCE(base_price_try,0) AS base_price_try,
COALESCE(gross_profit_usd_90d,0) AS gross_profit_usd_90d,
COALESCE(gross_profit_usd_180d,0) AS gross_profit_usd_180d,
COALESCE(gross_margin_90d,0) AS gross_margin_90d,
COALESCE(gross_margin_180d,0) AS gross_margin_180d,
COALESCE(unit_profit_cost_90d,0) AS unit_profit_cost_90d,
COALESCE(unit_profit_cost_180d,0) AS unit_profit_cost_180d,
COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d,
COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d,
COALESCE(market_count_90d,0) AS market_count_90d,
COALESCE(customer_count_90d,0) AS customer_count_90d,
COALESCE(sales_index_90d,0) AS sales_index_90d,
COALESCE(price_index_90d,0) AS price_index_90d,
COALESCE(margin_index_90d,0) AS margin_index_90d,
COALESCE(performance_score,0) AS performance_score,
COALESCE(performance_bucket,'') AS performance_bucket,
COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + idleWhere + `
)`
}
func productPerformanceSQLGroupExpr(field string) (string, bool) {
switch field {
case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_code", "yaka_kodu":
return field, true
case "market_key":
return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true
default:
return "", false
}
}
func productPerformanceSQLFilterWhere(filters []productPerformanceSQLGroupFilter) (string, []any, error) {
if len(filters) == 0 {
return "", nil, nil
}
parts := make([]string, 0, len(filters))
args := make([]any, 0, len(filters))
for _, filter := range filters {
expr, ok := productPerformanceSQLGroupExpr(filter.Field)
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(NULLIF(%s,''), '-') = $%d", expr, len(args)))
}
return "WHERE " + strings.Join(parts, " AND "), args, nil
}
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) { func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
switch mode { switch mode {
case "products": case "products":
+244 -71
View File
@@ -1,5 +1,22 @@
<template> <template>
<q-page class="product-performance-page q-pa-sm"> <q-page class="product-performance-page q-pa-sm">
<q-inner-loading :showing="pageBusy">
<q-spinner-gears size="52px" color="primary" />
</q-inner-loading>
<teleport to="body">
<div
v-if="pageBusy"
class="page-busy-overlay"
@click.stop
@mousedown.stop
@mouseup.stop
@touchstart.stop
@wheel.stop
>
<q-spinner-gears size="56px" color="primary" />
<div class="page-busy-label">Yükleniyor...</div>
</div>
</teleport>
<div class="row items-center justify-between q-col-gutter-sm q-mb-sm"> <div class="row items-center justify-between q-col-gutter-sm q-mb-sm">
<div class="col-12 col-md"> <div class="col-12 col-md">
<div class="text-h6">Ürün Performans ve Karlılık Analizi</div> <div class="text-h6">Ürün Performans ve Karlılık Analizi</div>
@@ -14,6 +31,7 @@
type="checkbox" type="checkbox"
inline inline
dense dense
:disable="pageBusy"
class="period-selector bg-white q-px-sm q-py-xs" class="period-selector bg-white q-px-sm q-py-xs"
/> />
<q-btn-dropdown <q-btn-dropdown
@@ -22,6 +40,7 @@
color="primary" color="primary"
:icon="allProductGroupsExpanded ? 'unfold_less' : 'unfold_more'" :icon="allProductGroupsExpanded ? 'unfold_less' : 'unfold_more'"
:label="allProductGroupsExpanded ? 'Detayları Kapat' : 'Tüm Detayları '" :label="allProductGroupsExpanded ? 'Detayları Kapat' : 'Tüm Detayları '"
:disable="pageBusy"
@click="toggleAllProductGroups" @click="toggleAllProductGroups"
> >
<div class="detail-level-menu"> <div class="detail-level-menu">
@@ -33,12 +52,12 @@
/> />
<q-separator class="q-my-sm" /> <q-separator class="q-my-sm" />
<div class="row items-center justify-end q-gutter-xs"> <div class="row items-center justify-end q-gutter-xs">
<q-btn dense flat color="grey-7" label="Kapat" @click="expandedGroups = {}" /> <q-btn dense flat color="grey-7" label="Kapat" :disable="pageBusy" @click="collapseAllProductGroups" />
<q-btn dense color="primary" label="Seçileni Aç" @click="expandSelectedProductGroups" /> <q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click="expandSelectedProductGroups" />
</div> </div>
</div> </div>
</q-btn-dropdown> </q-btn-dropdown>
<q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" @click="reload" /> <q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" :disable="pageBusy" @click="reload" />
</div> </div>
</div> </div>
@@ -53,23 +72,19 @@
</div> </div>
</div> </div>
<q-tabs <div class="performance-tab-grid q-mb-sm">
v-model="activeTab" <button
dense v-for="tab in performanceTabs"
class="bg-white text-grey-8 q-mb-sm" :key="tab.name"
active-color="primary" type="button"
indicator-color="primary" :class="['performance-tab-button', { active: activeTab === tab.name }]"
align="left" :disabled="pageBusy"
> @click="switchPerformanceTab(tab.name)"
<q-tab name="products" icon="inventory_2" label="Ürün KPI" /> >
<q-tab name="sales_color_yaka_market_customer" icon="palette" label="Renk > Yaka > Piyasa > Müşteri" /> <q-icon :name="tab.icon" size="16px" />
<q-tab name="idle" icon="warning" label="Atıl Stok / Maliyet" /> <span>{{ tab.label }}</span>
<q-tab name="sales_product_country_segment_market_customer" icon="account_tree" label="Ürün > Ülke > Segment > Piyasa > Müşteri" /> </button>
<q-tab name="sales_market_customer_product" icon="groups" label="Piyasa > Müşteri > Ürün Satış KPI" /> </div>
<q-tab name="sales_country_segment_market_customer_product" icon="public" label="Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI" />
<q-tab name="order_product_customers" icon="assignment_ind" label="Ürün > Piyasa > Müşteri Sipariş" />
<q-tab name="order_market_details" icon="receipt_long" label="Piyasa > Müşteri > Ürün Sipariş" />
</q-tabs>
<q-table <q-table
v-if="activeTab === 'general'" v-if="activeTab === 'general'"
@@ -81,6 +96,8 @@
:columns="generalColumns" :columns="generalColumns"
:loading="loading" :loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }" :pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
virtual-scroll
:virtual-scroll-item-size="64"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -189,6 +206,8 @@
:columns="orderProductCustomerColumns" :columns="orderProductCustomerColumns"
:loading="loading || backendGroupedLoading" :loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }" :pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
virtual-scroll
:virtual-scroll-item-size="64"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -373,6 +392,8 @@
:columns="orderMarketDetailColumns" :columns="orderMarketDetailColumns"
:loading="loading || backendGroupedLoading" :loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }" :pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
virtual-scroll
:virtual-scroll-item-size="64"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -507,6 +528,8 @@
:columns="productColumns" :columns="productColumns"
:loading="loading || backendGroupedLoading" :loading="loading || backendGroupedLoading"
:pagination="pagination" :pagination="pagination"
virtual-scroll
:virtual-scroll-item-size="64"
@request="onRequest" @request="onRequest"
> >
<template #header-cell="props"> <template #header-cell="props">
@@ -658,6 +681,8 @@
:columns="idleColumns" :columns="idleColumns"
:loading="loading || backendGroupedLoading" :loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100 }" :pagination="{ rowsPerPage: 100 }"
virtual-scroll
:virtual-scroll-item-size="64"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -838,6 +863,8 @@
:columns="marketColumns" :columns="marketColumns"
:loading="loading" :loading="loading"
:pagination="{ rowsPerPage: 100 }" :pagination="{ rowsPerPage: 100 }"
virtual-scroll
:virtual-scroll-item-size="64"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -936,6 +963,8 @@
:columns="visibleSalesBreakdownColumns" :columns="visibleSalesBreakdownColumns"
:loading="loading || backendGroupedLoading" :loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }" :pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
virtual-scroll
:virtual-scroll-item-size="64"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -1091,6 +1120,8 @@
:columns="customerColumns" :columns="customerColumns"
:loading="loading" :loading="loading"
:pagination="{ rowsPerPage: 100 }" :pagination="{ rowsPerPage: 100 }"
virtual-scroll
:virtual-scroll-item-size="64"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -1190,6 +1221,8 @@
:columns="countryColumns" :columns="countryColumns"
:loading="loading" :loading="loading"
:pagination="{ rowsPerPage: 100 }" :pagination="{ rowsPerPage: 100 }"
virtual-scroll
:virtual-scroll-item-size="64"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -1396,6 +1429,8 @@
:columns="detailStockColumns" :columns="detailStockColumns"
:loading="detailLoading" :loading="detailLoading"
:pagination="{ rowsPerPage: 50 }" :pagination="{ rowsPerPage: 50 }"
virtual-scroll
:virtual-scroll-item-size="48"
/> />
</div> </div>
<div class="col-12 col-md-8"> <div class="col-12 col-md-8">
@@ -1409,6 +1444,8 @@
:columns="detailSalesColumns" :columns="detailSalesColumns"
:loading="detailLoading" :loading="detailLoading"
:pagination="{ rowsPerPage: 50 }" :pagination="{ rowsPerPage: 50 }"
virtual-scroll
:virtual-scroll-item-size="48"
/> />
</div> </div>
</div> </div>
@@ -1422,8 +1459,11 @@
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import api from 'src/services/api' import api from 'src/services/api'
import { useProductPerformanceStore } from 'src/stores/productPerformanceStore'
import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters' import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters'
const performanceStore = useProductPerformanceStore()
const loading = ref(false) const loading = ref(false)
const rows = ref([]) const rows = ref([])
const generalRows = ref([]) const generalRows = ref([])
@@ -1466,7 +1506,10 @@ const detailSalesRows = ref([])
const detailStockSizes = ref([]) const detailStockSizes = ref([])
const backendGroupedRows = ref({}) const backendGroupedRows = ref({})
const backendGroupedLoading = ref(false) const backendGroupedLoading = ref(false)
const imagePrimeLoading = ref(false)
const filterBusy = ref(false)
let backendGroupedTimer = null let backendGroupedTimer = null
let imagePrimeActiveRequests = 0
const columnFilters = reactive({}) const columnFilters = reactive({})
const columnFilterSearch = reactive({}) const columnFilterSearch = reactive({})
@@ -1482,6 +1525,17 @@ const selectedExpandLevelKeysByTab = reactive({
order_market_details: ['market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu'] order_market_details: ['market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu']
}) })
const productKpiFetchLimit = 50000 const productKpiFetchLimit = 50000
const performanceTabs = [
{ name: 'products', icon: 'inventory_2', label: 'Ürün KPI' },
{ name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Renk > Yaka > Piyasa > Müşteri' },
{ name: 'idle', icon: 'warning', label: 'Atıl Stok / Maliyet' },
{ name: 'sales_product_country_segment_market_customer', icon: 'account_tree', label: 'Ürün > Ülke > Segment > Piyasa > Müşteri' },
{ name: 'sales_market_customer_product', icon: 'groups', label: 'Piyasa > Müşteri > Ürün Satış KPI' },
{ name: 'sales_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' },
{ name: 'order_product_customers', icon: 'assignment_ind', label: 'Ürün > Piyasa > Müşteri Sipariş' },
{ name: 'order_market_details', icon: 'receipt_long', label: 'Piyasa > Müşteri > Ürün Sipariş' }
]
const pageBusy = computed(() => loading.value || backendGroupedLoading.value || imagePrimeLoading.value || filterBusy.value)
const pagination = ref({ const pagination = ref({
page: 1, page: 1,
@@ -2516,13 +2570,17 @@ function toggleGroup (key) {
function toggleAllProductGroups () { function toggleAllProductGroups () {
const keys = productAutoExpandKeys.value const keys = productAutoExpandKeys.value
if (allProductGroupsExpanded.value) { if (allProductGroupsExpanded.value) {
expandedGroups.value = {} collapseAllProductGroups()
scheduleLoadBackendGroupedRows()
return return
} }
expandSelectedProductGroups() expandSelectedProductGroups()
} }
function collapseAllProductGroups () {
expandedGroups.value = {}
scheduleLoadBackendGroupedRows()
}
function expandSelectedProductGroups () { function expandSelectedProductGroups () {
const keys = productAutoExpandKeys.value const keys = productAutoExpandKeys.value
const next = { ...expandedGroups.value } const next = { ...expandedGroups.value }
@@ -2617,19 +2675,47 @@ function isColumnFilterValueSelected (tableKey, name, value) {
} }
function toggleColumnFilterValue (tableKey, name, value) { function toggleColumnFilterValue (tableKey, name, value) {
const key = filterKey(tableKey, name) runWithFilterBusy(() => {
const selected = selectedColumnFilters(tableKey, name) const key = filterKey(tableKey, name)
columnFilters[key] = selected.includes(value) const selected = selectedColumnFilters(tableKey, name)
? selected.filter(item => item !== value) columnFilters[key] = selected.includes(value)
: [...selected, value] ? selected.filter(item => item !== value)
: [...selected, value]
})
} }
function clearColumnFilter (tableKey, name) { function clearColumnFilter (tableKey, name) {
columnFilters[filterKey(tableKey, name)] = [] runWithFilterBusy(() => {
columnFilters[filterKey(tableKey, name)] = []
})
} }
function selectAllColumnFilterOptions (tableKey, name) { function selectAllColumnFilterOptions (tableKey, name) {
columnFilters[filterKey(tableKey, name)] = columnFilterOptions(tableKey, name).map(option => option.value) runWithFilterBusy(() => {
columnFilters[filterKey(tableKey, name)] = columnFilterOptions(tableKey, name).map(option => option.value)
})
}
function runWithFilterBusy (apply) {
filterBusy.value = true
const done = () => {
filterBusy.value = false
}
const execute = () => {
apply()
if (typeof window === 'undefined') {
setTimeout(done, 0)
return
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(done)
})
}
if (typeof window === 'undefined') {
execute()
return
}
window.requestAnimationFrame(execute)
} }
function filterRowsForTable (tableKey, sourceRows, sourceColumns) { function filterRowsForTable (tableKey, sourceRows, sourceColumns) {
@@ -2931,6 +3017,12 @@ async function fetchProductImagesForRow (row) {
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) { if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) {
return imageListByKey.value[key] return imageListByKey.value[key]
} }
if (performanceStore.hasImageKey(key)) {
const urls = performanceStore.imageList(key)
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
return urls
}
const code = String(row?.image_product_code || row?.product_code || '').trim() const code = String(row?.image_product_code || row?.product_code || '').trim()
if (!code) return [] if (!code) return []
@@ -2949,10 +3041,12 @@ async function fetchProductImagesForRow (row) {
}) })
const list = Array.isArray(resp?.data) ? resp.data : [] const list = Array.isArray(resp?.data) ? resp.data : []
const urls = list.map(resolveProductImageUrl).filter(Boolean) const urls = list.map(resolveProductImageUrl).filter(Boolean)
performanceStore.setImageCache(key, urls)
imageListByKey.value = { ...imageListByKey.value, [key]: urls } imageListByKey.value = { ...imageListByKey.value, [key]: urls }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' } imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
return urls return urls
} catch { } catch {
performanceStore.setImageCache(key, [])
imageListByKey.value = { ...imageListByKey.value, [key]: [] } imageListByKey.value = { ...imageListByKey.value, [key]: [] }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' } imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' }
return [] return []
@@ -2960,44 +3054,38 @@ async function fetchProductImagesForRow (row) {
} }
function getCachedProductImageUrl (row) { function getCachedProductImageUrl (row) {
return imageUrlByKey.value[productImageKey(row)] || '' const key = productImageKey(row)
return imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
} }
async function primeProductImages (sourceRows) { async function primeProductImages (sourceRows) {
imagePrimeActiveRequests += 1
imagePrimeLoading.value = true
const items = [] const items = []
const seen = new Set() const seen = new Set()
for (const row of sourceRows || []) {
const key = productImageKey(row)
if (!key || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) continue
seen.add(key)
items.push({
key,
code: String(row?.image_product_code || row?.product_code || '').trim(),
dim1: String(row?.image_color_code || row?.color_code || '').trim(),
dim3: String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
})
if (items.length >= 160) break
}
if (!items.length) return
try { try {
const resp = await api.post('/product-images/batch', { items }, { timeout: 60000 }) for (const row of sourceRows || []) {
const key = productImageKey(row)
const code = String(row?.image_product_code || row?.product_code || '').trim()
if (!key || !code || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) continue
seen.add(key)
items.push({
key,
code,
dim1: String(row?.image_color_code || row?.color_code || '').trim(),
dim3: String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
})
if (items.length >= 160) break
}
if (!items.length) return
const batch = await performanceStore.fetchImageBatch(items)
const nextLists = { ...imageListByKey.value } const nextLists = { ...imageListByKey.value }
const nextUrls = { ...imageUrlByKey.value } const nextUrls = { ...imageUrlByKey.value }
const returned = new Set()
for (const item of Array.isArray(resp?.data) ? resp.data : []) {
const key = String(item?.key || '').trim()
if (!key) continue
returned.add(key)
const urls = (Array.isArray(item.images) ? item.images : []).map(resolveProductImageUrl).filter(Boolean)
nextLists[key] = urls
nextUrls[key] = urls[0] || ''
}
for (const item of items) { for (const item of items) {
if (!returned.has(item.key)) { const urls = batch?.lists?.[item.key] || performanceStore.imageList(item.key) || []
nextLists[item.key] = [] nextLists[item.key] = urls
nextUrls[item.key] = '' nextUrls[item.key] = batch?.urls?.[item.key] || urls[0] || ''
}
} }
imageListByKey.value = nextLists imageListByKey.value = nextLists
imageUrlByKey.value = nextUrls imageUrlByKey.value = nextUrls
@@ -3007,6 +3095,9 @@ async function primeProductImages (sourceRows) {
color_code: item.dim1, color_code: item.dim1,
yaka_kodu: item.dim3 yaka_kodu: item.dim3
}))) })))
} finally {
imagePrimeActiveRequests = Math.max(0, imagePrimeActiveRequests - 1)
imagePrimeLoading.value = imagePrimeActiveRequests > 0
} }
} }
@@ -3080,29 +3171,34 @@ function activeExpandedGroupKeys () {
} }
function scheduleLoadBackendGroupedRows () { function scheduleLoadBackendGroupedRows () {
if (backendGroupedSupportedTab(activeTab.value)) backendGroupedLoading.value = true
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer) if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
backendGroupedTimer = window.setTimeout(() => { backendGroupedTimer = window.setTimeout(() => {
void loadBackendGroupedRows() void loadBackendGroupedRows()
}, 120) }, 120)
} }
function switchPerformanceTab (tabName) {
if (!tabName || tabName === activeTab.value || pageBusy.value) return
backendGroupedLoading.value = true
activeTab.value = tabName
}
async function loadBackendGroupedRows () { async function loadBackendGroupedRows () {
const tabKey = activeTab.value const tabKey = activeTab.value
if (!backendGroupedSupportedTab(tabKey)) return if (!backendGroupedSupportedTab(tabKey)) return
backendGroupedLoading.value = true backendGroupedLoading.value = true
const params = {
mode: tabKey,
groupLevels: activeGroupLevels.value.map(level => level.key),
expandedKeys: activeExpandedGroupKeys(),
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
}
try { try {
const resp = await api.get('/pricing/product-performance/grouped', { const rows = await performanceStore.fetchGroupedRows(params)
params: {
mode: tabKey,
group_levels: activeGroupLevels.value.map(level => level.key).join(','),
expanded_keys: activeExpandedGroupKeys().join(','),
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
},
timeout: 90000
})
backendGroupedRows.value = { backendGroupedRows.value = {
...backendGroupedRows.value, ...backendGroupedRows.value,
[tabKey]: Array.isArray(resp?.data) ? resp.data : [] [tabKey]: Array.isArray(rows) ? rows : []
} }
} catch (err) { } catch (err) {
console.warn('product performance grouped rows failed', err) console.warn('product performance grouped rows failed', err)
@@ -3168,7 +3264,7 @@ async function reload () {
await loadOrderMarketDetails(false) await loadOrderMarketDetails(false)
} }
await loadBackendGroupedRows() await loadBackendGroupedRows()
void primeProductImages(rows.value) await primeProductImages(rows.value)
} catch (err) { } catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' }) Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
} finally { } finally {
@@ -3182,7 +3278,7 @@ async function loadOrderProductCustomers (showLoading = true) {
const resp = await api.get('/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 }) const resp = await api.get('/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 })
orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow) orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
orderProductCustomersLoaded.value = true orderProductCustomersLoaded.value = true
void primeProductImages(orderProductCustomerRows.value) await primeProductImages(orderProductCustomerRows.value)
} catch (err) { } catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün müşteri sipariş analizi alınamadı' }) Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün müşteri sipariş analizi alınamadı' })
} finally { } finally {
@@ -3215,7 +3311,7 @@ async function loadOrderAnalysis (showLoading = true) {
orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow) orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow)
orderCustomerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(normalizeOrderGroupRow) orderCustomerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(normalizeOrderGroupRow)
orderAnalysisLoaded.value = true orderAnalysisLoaded.value = true
void primeProductImages(orderAnalysisRows.value) await primeProductImages(orderAnalysisRows.value)
} catch (err) { } catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Sipariş analiz verisi alınamadı' }) Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Sipariş analiz verisi alınamadı' })
} finally { } finally {
@@ -3271,9 +3367,70 @@ onMounted(reload)
<style scoped> <style scoped>
.product-performance-page { .product-performance-page {
position: relative;
background: #f6f7f9; background: #f6f7f9;
} }
.page-busy-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(246, 247, 249, 0.82);
backdrop-filter: blur(2px);
pointer-events: all;
}
.page-busy-label {
color: #1f2937;
font-size: 13px;
font-weight: 700;
}
.performance-tab-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
gap: 6px;
}
.performance-tab-button {
min-height: 38px;
padding: 6px 8px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid #d5dce5;
border-radius: 6px;
background: #fff;
color: #4b5563;
font-size: 11px;
font-weight: 700;
line-height: 1.15;
text-align: center;
cursor: pointer;
}
.performance-tab-button span {
white-space: normal;
overflow-wrap: anywhere;
}
.performance-tab-button.active {
border-color: var(--q-primary);
color: var(--q-primary);
background: #eef6ff;
}
.performance-tab-button:disabled {
cursor: wait;
opacity: 0.62;
}
.metric-card { .metric-card {
min-height: 78px; min-height: 78px;
} }
@@ -3311,8 +3468,17 @@ onMounted(reload)
white-space: normal; white-space: normal;
} }
.performance-table :deep(.q-table tbody tr),
.performance-table :deep(.q-table tbody td) {
height: auto;
min-height: 48px;
}
.performance-table :deep(.q-table tbody td) { .performance-table :deep(.q-table tbody td) {
max-width: 320px; max-width: 320px;
padding-top: 6px;
padding-bottom: 6px;
overflow-wrap: anywhere;
} }
.performance-table :deep(.q-table tbody td span:not(.q-badge):not(.q-icon)) { .performance-table :deep(.q-table tbody td span:not(.q-badge):not(.q-icon)) {
@@ -3323,6 +3489,13 @@ onMounted(reload)
line-height: 1.25; line-height: 1.25;
} }
.performance-table :deep(.q-table tbody td .q-field),
.performance-table :deep(.q-table tbody td input),
.performance-table :deep(.q-table tbody td textarea) {
min-width: 96px;
max-width: 100%;
}
.performance-table :deep(.q-table th:first-child), .performance-table :deep(.q-table th:first-child),
.performance-table :deep(.q-table td:first-child) { .performance-table :deep(.q-table td:first-child) {
border-left: 1px solid #d5dce5; border-left: 1px solid #d5dce5;
+166
View File
@@ -0,0 +1,166 @@
import { defineStore } from 'pinia'
import api from 'src/services/api'
const GROUPED_TTL_MS = 2 * 60 * 1000
const IMAGE_TTL_MS = 15 * 60 * 1000
function normalizeUploadsPath (storagePath) {
const raw = String(storagePath || '').trim()
if (!raw) return ''
const normalized = raw.replace(/\\/g, '/')
const idx = normalized.toLowerCase().indexOf('/uploads/')
if (idx >= 0) return normalized.slice(idx)
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
return ''
}
function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return ''
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) return contentURL
if (contentURL.startsWith('/')) return `/api${contentURL}`
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
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()
return fileName ? `/uploads/image/${fileName}` : ''
}
function sortedList (value) {
if (!Array.isArray(value)) return []
return value.map(x => String(x || '').trim()).filter(Boolean).sort()
}
export const useProductPerformanceStore = defineStore('product-performance-store', {
state: () => ({
groupedRowsByKey: {},
groupedLoadedAtByKey: {},
groupedLoadingByKey: {},
groupedInFlightByKey: {},
imageUrlByKey: {},
imageListByKey: {},
imageLoadedAtByKey: {}
}),
getters: {
groupedRows: state => key => state.groupedRowsByKey[key] || [],
hasImageKey: state => key => Object.prototype.hasOwnProperty.call(state.imageListByKey, key),
imageUrl: state => key => state.imageUrlByKey[key] || '',
imageList: state => key => state.imageListByKey[key] || []
},
actions: {
groupedCacheKey (params = {}) {
return JSON.stringify({
mode: String(params.mode || ''),
groupLevels: sortedList(params.groupLevels),
expandedKeys: sortedList(params.expandedKeys),
limit: Number(params.limit || 0)
})
},
async fetchGroupedRows (params = {}, options = {}) {
const key = this.groupedCacheKey(params)
const now = Date.now()
const loadedAt = Number(this.groupedLoadedAtByKey[key] || 0)
const cached = this.groupedRowsByKey[key]
if (!options.force && Array.isArray(cached) && now - loadedAt < GROUPED_TTL_MS) return cached
if (this.groupedInFlightByKey[key]) return this.groupedInFlightByKey[key]
this.groupedLoadingByKey = { ...this.groupedLoadingByKey, [key]: true }
const request = api.get('/pricing/product-performance/grouped', {
params: {
mode: params.mode,
group_levels: Array.isArray(params.groupLevels) ? params.groupLevels.join(',') : params.groupLevels,
expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys.join(',') : params.expandedKeys,
limit: params.limit
},
timeout: 90000
}).then(resp => {
const rows = Array.isArray(resp?.data) ? resp.data : []
this.groupedRowsByKey = { ...this.groupedRowsByKey, [key]: rows }
this.groupedLoadedAtByKey = { ...this.groupedLoadedAtByKey, [key]: Date.now() }
return rows
}).finally(() => {
const nextLoading = { ...this.groupedLoadingByKey }
const nextInFlight = { ...this.groupedInFlightByKey }
delete nextLoading[key]
delete nextInFlight[key]
this.groupedLoadingByKey = nextLoading
this.groupedInFlightByKey = nextInFlight
})
this.groupedInFlightByKey = { ...this.groupedInFlightByKey, [key]: request }
return request
},
setImageCache (key, urls = []) {
const normalizedKey = String(key || '').trim()
if (!normalizedKey) return
const list = Array.isArray(urls) ? urls.filter(Boolean) : []
this.imageListByKey = { ...this.imageListByKey, [normalizedKey]: list }
this.imageUrlByKey = { ...this.imageUrlByKey, [normalizedKey]: list[0] || '' }
this.imageLoadedAtByKey = { ...this.imageLoadedAtByKey, [normalizedKey]: Date.now() }
},
async fetchImageBatch (items = [], options = {}) {
const now = Date.now()
const pending = []
const seen = new Set()
for (const item of items) {
const key = String(item?.key || '').trim()
if (!key || seen.has(key)) continue
seen.add(key)
const loadedAt = Number(this.imageLoadedAtByKey[key] || 0)
if (!options.force && Object.prototype.hasOwnProperty.call(this.imageListByKey, key) && now - loadedAt < IMAGE_TTL_MS) continue
pending.push({
key,
code: String(item?.code || '').trim(),
dim1: String(item?.dim1 || '').trim(),
dim3: String(item?.dim3 || '').trim()
})
}
if (!pending.length) {
const keys = Array.from(seen.values())
return {
lists: Object.fromEntries(keys.map(key => [key, this.imageListByKey[key] || []])),
urls: Object.fromEntries(keys.map(key => [key, this.imageUrlByKey[key] || '']))
}
}
const resp = await api.post('/product-images/batch', { items: pending }, { timeout: 60000 })
const returned = new Set()
const lists = {}
const urlsByKey = {}
for (const item of Array.isArray(resp?.data) ? resp.data : []) {
const key = String(item?.key || '').trim()
if (!key) continue
returned.add(key)
const urls = (Array.isArray(item.images) ? item.images : []).map(resolveProductImageUrl).filter(Boolean)
this.setImageCache(key, urls)
lists[key] = urls
urlsByKey[key] = urls[0] || ''
}
for (const item of pending) {
if (returned.has(item.key)) continue
this.setImageCache(item.key, [])
lists[item.key] = []
urlsByKey[item.key] = ''
}
return { lists, urls: urlsByKey }
}
}
})