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

This commit is contained in:
M_Kececi
2026-07-02 20:02:19 +03:00
parent 593a7d4816
commit 2fc9699ff1
3 changed files with 359 additions and 103 deletions
+75 -37
View File
@@ -670,8 +670,10 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc
return nil, 0, err return nil, 0, err
} }
limit := f.Limit limit := f.Limit
if limit <= 0 || limit > 500 { if limit <= 0 {
limit = 100 limit = 100
} else if limit > 50000 {
limit = 50000
} }
page := f.Page page := f.Page
if page <= 0 { if page <= 0 {
@@ -741,6 +743,25 @@ KPI AS (
FROM mk_product_performance_kpi_daily FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT kpi_date FROM Latest) WHERE kpi_date = (SELECT kpi_date FROM Latest)
), ),
VariantStock AS (
SELECT
product_code,
color_code,
yaka_kodu,
MAX(stock_qty) AS stock_qty,
MAX(cost_price_usd) AS cost_price_usd,
BOOL_OR(performance_bucket IN ('STOK_RISKI','TAKIP')) AS has_risk_bucket,
SUM(COALESCE(sales_qty_90d,0)) AS sales_qty_90d
FROM KPI
GROUP BY product_code, color_code, yaka_kodu
),
StockTotals AS (
SELECT
COALESCE(SUM(stock_qty),0) AS total_stock,
COALESCE(SUM(stock_qty * cost_price_usd),0) AS stock_cost_usd,
COALESCE(SUM(CASE WHEN has_risk_bucket AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0) AS risk_cost_usd
FROM VariantStock
),
Sales90 AS ( Sales90 AS (
SELECT SELECT
COUNT(DISTINCT market_key) AS market_count_90d, COUNT(DISTINCT market_key) AS market_count_90d,
@@ -754,9 +775,9 @@ SELECT
COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN'), COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN'),
COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI'), COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI'),
COUNT(*) FILTER (WHERE performance_bucket='STOKSUZ_TALEP'), COUNT(*) FILTER (WHERE performance_bucket='STOKSUZ_TALEP'),
COALESCE(SUM(stock_qty),0), COALESCE(MAX(StockTotals.total_stock),0),
COALESCE(SUM(stock_qty * cost_price_usd),0), COALESCE(MAX(StockTotals.stock_cost_usd),0),
COALESCE(SUM(CASE WHEN performance_bucket IN ('STOK_RISKI','TAKIP') AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0), COALESCE(MAX(StockTotals.risk_cost_usd),0),
COALESCE(SUM(sales_qty_90d),0), COALESCE(SUM(sales_qty_90d),0),
COALESCE(SUM(sales_usd_90d),0), COALESCE(SUM(sales_usd_90d),0),
COALESCE(SUM(gross_profit_usd_90d),0), COALESCE(SUM(gross_profit_usd_90d),0),
@@ -765,6 +786,7 @@ SELECT
COALESCE(to_char(MAX(updated_at),'YYYY-MM-DD HH24:MI:SS'),'') COALESCE(to_char(MAX(updated_at),'YYYY-MM-DD HH24:MI:SS'),'')
FROM KPI FROM KPI
CROSS JOIN Sales90 CROSS JOIN Sales90
CROSS JOIN StockTotals
`).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.MarketCount90, &s.CustomerCount90, &s.UpdatedAt) `).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.MarketCount90, &s.CustomerCount90, &s.UpdatedAt)
return s, err return s, err
} }
@@ -2007,10 +2029,22 @@ func ListProductPerformanceStockSizes(ctx context.Context, productCode, colorCod
return nil, fmt.Errorf("mssql db nil") return nil, fmt.Errorf("mssql db nil")
} }
rows, err := db.MssqlDB.QueryContext(ctx, ` rows, err := db.MssqlDB.QueryContext(ctx, `
;WITH ActiveWarehouses AS (
SELECT WarehouseCode
FROM (VALUES
('1-0-12'),('1.01.2014'),('1.02.2005'),('1.02.2004'),('1-0-43'),
('4.02.2001'),('1-0-55'),('1.01.2003'),('1-0-21'),('1-0-2'),
('1.01.2004'),('1-0-49'),('1-0-37'),('1-0-29'),('1-0-28'),
('1-0-10'),('100'),('1.02.2006'),('1-0-42'),('1.01.2002'),
('1-0-52')
) W(WarehouseCode)
)
SELECT SELECT
SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))), SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))),
StockQty = SUM(S.InventoryQty1) StockQty = SUM(S.InventoryQty1)
FROM StockWithCost S WITH(NOLOCK) FROM StockWithCost S WITH(NOLOCK)
INNER JOIN ActiveWarehouses W
ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
WHERE S.ItemTypeCode = 1 WHERE S.ItemTypeCode = 1
AND LTRIM(RTRIM(S.ItemCode)) = @p1 AND LTRIM(RTRIM(S.ItemCode)) = @p1
AND LTRIM(RTRIM(ISNULL(S.ColorCode, ''))) = @p2 AND LTRIM(RTRIM(ISNULL(S.ColorCode, ''))) = @p2
@@ -2068,39 +2102,43 @@ func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) {
func productPerformanceOrderBy(sortBy string, desc bool) string { func productPerformanceOrderBy(sortBy string, desc bool) string {
allowed := map[string]string{ allowed := map[string]string{
"product_code": "product_code", "product_code": "product_code",
"color_code": "color_code", "color_code": "color_code",
"yaka_kodu": "yaka_kodu", "yaka_kodu": "yaka_kodu",
"item_description": "item_description", "item_description": "item_description",
"kategori": "kategori", "kategori": "kategori",
"urun_ilk_grubu": "urun_ilk_grubu", "urun_ilk_grubu": "urun_ilk_grubu",
"askili_yan": "askili_yan", "askili_yan": "askili_yan",
"urun_ana_grubu": "urun_ana_grubu", "urun_ana_grubu": "urun_ana_grubu",
"urun_alt_grubu": "urun_alt_grubu", "urun_alt_grubu": "urun_alt_grubu",
"market_key": "market_key", "market_key": "market_key",
"stock_qty": "stock_qty", "stock_qty": "stock_qty",
"sales_qty_90d": "sales_qty_90d", "sales_qty_90d": "sales_qty_90d",
"sales_qty_180d": "sales_qty_180d", "sales_qty_180d": "sales_qty_180d",
"sales_usd_90d": "sales_usd_90d", "sales_usd_90d": "sales_usd_90d",
"sales_usd_180d": "sales_usd_180d", "sales_usd_180d": "sales_usd_180d",
"stock_days_90d": "stock_days_90d", "stock_days_90d": "stock_days_90d",
"stock_days_180d": "stock_days_180d", "stock_days_180d": "stock_days_180d",
"avg_price_usd_90d": "avg_price_usd_90d", "avg_price_usd_90d": "avg_price_usd_90d",
"avg_price_usd_180d": "avg_price_usd_180d", "avg_price_usd_180d": "avg_price_usd_180d",
"base_price_usd": "base_price_usd", "base_price_usd": "base_price_usd",
"cost_price_usd": "cost_price_usd", "cost_price_usd": "cost_price_usd",
"unit_profit_base_90d": "unit_profit_base_90d", "unit_profit_base_90d": "unit_profit_base_90d",
"unit_profit_cost_90d": "unit_profit_cost_90d", "unit_profit_cost_90d": "unit_profit_cost_90d",
"unit_profit_base_180d": "unit_profit_base_180d", "unit_profit_base_180d": "unit_profit_base_180d",
"unit_profit_cost_180d": "unit_profit_cost_180d", "unit_profit_cost_180d": "unit_profit_cost_180d",
"gross_margin_90d": "gross_margin_90d", "gross_margin_base_90d": "(CASE WHEN COALESCE(sales_usd_90d,0) <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * COALESCE(base_price_usd,0))) / NULLIF(sales_usd_90d,0) END)",
"gross_margin_180d": "gross_margin_180d", "gross_margin_cost_90d": "gross_margin_90d",
"market_count_90d": "market_count_90d", "gross_margin_base_180d": "(CASE WHEN COALESCE(sales_usd_180d,0) <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * COALESCE(base_price_usd,0))) / NULLIF(sales_usd_180d,0) END)",
"customer_count_90d": "customer_count_90d", "gross_margin_cost_180d": "gross_margin_180d",
"sales_index_90d": "sales_index_90d", "gross_margin_90d": "gross_margin_90d",
"performance_score": "performance_score", "gross_margin_180d": "gross_margin_180d",
"performance_bucket": "performance_bucket", "market_count_90d": "market_count_90d",
"last_sale_date": "last_sale_date", "customer_count_90d": "customer_count_90d",
"sales_index_90d": "sales_index_90d",
"performance_score": "performance_score",
"performance_bucket": "performance_bucket",
"last_sale_date": "last_sale_date",
} }
col := allowed[strings.TrimSpace(sortBy)] col := allowed[strings.TrimSpace(sortBy)]
if col == "" { if col == "" {
+5 -4
View File
@@ -216,10 +216,11 @@ func productPerformanceStockSQL() string {
;WITH ActiveWarehouses AS ( ;WITH ActiveWarehouses AS (
SELECT WarehouseCode SELECT WarehouseCode
FROM (VALUES FROM (VALUES
('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'), ('1-0-12'),('1.01.2014'),('1.02.2005'),('1.02.2004'),('1-0-43'),
('100'),('1-0-28'),('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'), ('4.02.2001'),('1-0-55'),('1.01.2003'),('1-0-21'),('1-0-2'),
('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'),('1-0-33'),('101'), ('1.01.2004'),('1-0-49'),('1-0-37'),('1-0-29'),('1-0-28'),
('1-014'),('1-0-49'),('1-0-36') ('1-0-10'),('100'),('1.02.2006'),('1-0-42'),('1.01.2002'),
('1-0-52')
) W(WarehouseCode) ) W(WarehouseCode)
), ),
Raw AS ( Raw AS (
+279 -62
View File
@@ -449,32 +449,28 @@
</template> </template>
<template #body="props"> <template #body="props">
<q-tr v-if="props.row.__group" :props="props" class="group-row"> <q-tr
<q-td :colspan="columns.length" :style="{ paddingLeft: `${8 + props.row.level * 18}px` }"> v-if="props.row.__group"
<q-btn :props="props"
flat :class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
dense >
round <q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
size="sm" <div
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'" v-if="col.name === groupLabelColumnName(props.row)"
@click.stop="toggleGroup(props.row.key)" class="group-cell-label"
/> :style="{ paddingLeft: `${props.row.level * 18}px` }"
<span class="group-title">{{ props.row.label }}</span> >
<span class="group-meta"> <q-btn
{{ formatNumber(props.row.count, 0) }} satır · flat
Stok {{ formatNumber(props.row.stock_qty, 0) }} · dense
90G {{ formatNumber(props.row.sales_qty_90d, 0) }} · round
180G {{ formatNumber(props.row.sales_qty_180d, 0) }} · size="sm"
Piyasa {{ formatNumber(props.row.market_count_90d, 0) }} · :icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
Müşteri {{ formatNumber(props.row.customer_count_90d, 0) }} · @click.stop="toggleGroup(props.row.key)"
Ort.Satış {{ formatMoney(props.row.avg_price_usd_90d, 'USD') }} · />
Taban {{ formatMoney(props.row.avg_base_price_usd, 'USD') }} · <span class="group-title">{{ props.row.label }}</span>
Çıplak {{ formatMoney(props.row.avg_cost_price_usd, 'USD') }} · </div>
90G K/Z {{ formatMoney(props.row.gross_profit_usd_90d, 'USD') }} · <span v-else>{{ formatGroupCell(props.row, col.name) }}</span>
90G Marj {{ formatPercent(props.row.gross_margin_90d) }} ·
180G K/Z {{ formatMoney(props.row.gross_profit_usd_180d, 'USD') }} ·
180G Marj {{ formatPercent(props.row.gross_margin_180d) }}
</span>
</q-td> </q-td>
</q-tr> </q-tr>
<q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)"> <q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)">
@@ -1059,10 +1055,11 @@ const detailStockSizes = ref([])
const columnFilters = reactive({}) const columnFilters = reactive({})
const columnFilterSearch = reactive({}) const columnFilterSearch = reactive({})
const expandedGroups = ref({}) const expandedGroups = ref({})
const productKpiFetchLimit = 50000
const pagination = ref({ const pagination = ref({
page: 1, page: 1,
rowsPerPage: 500, rowsPerPage: 100,
rowsNumber: 0, rowsNumber: 0,
sortBy: 'performance_score', sortBy: 'performance_score',
descending: true descending: true
@@ -1129,8 +1126,10 @@ const columns = [
{ name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_90d', label: '90G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_90d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_usd_90d', label: '90G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_180d', label: '180G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_usd_180d', label: '180G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true }, { name: 'gross_margin_base_90d', label: '90G Taban Marj', field: row => formatPercent(row.gross_margin_base_90d), align: 'right', sortable: true },
{ name: 'gross_margin_180d', label: '180G Marj', field: 'gross_margin_180d', align: 'right', sortable: true }, { name: 'gross_margin_cost_90d', label: '90G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_90d), align: 'right', sortable: true },
{ name: 'gross_margin_base_180d', label: '180G Taban Marj', field: row => formatPercent(row.gross_margin_base_180d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_180d', label: '180G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_180d), align: 'right', sortable: true },
{ name: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 0), align: 'right', sortable: true }, { name: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 0), align: 'right', sortable: true },
{ name: 'customer_count_90d', label: '90G Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true }, { name: 'customer_count_90d', label: '90G Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true },
{ name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true }, { name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
@@ -1164,7 +1163,8 @@ const generalColumns = [
{ name: 'unit_profit_base_total', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_base_total', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_total', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_total, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_cost_total', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_total', label: 'Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_usd_total', label: 'Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_total', label: 'Genel Marj', field: row => formatPercent(row.gross_margin_total), align: 'right', sortable: true }, { name: 'gross_margin_base_total', label: 'Genel Taban Marj', field: row => formatPercent(row.gross_margin_base_total), align: 'right', sortable: true },
{ name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true },
{ name: 'market_count_total', label: 'Toplam Piyasa', field: row => formatNumber(row.market_count_total, 0), align: 'right', sortable: true }, { name: 'market_count_total', label: 'Toplam Piyasa', field: row => formatNumber(row.market_count_total, 0), align: 'right', sortable: true },
{ name: 'customer_count_total', label: 'Toplam Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true }, { name: 'customer_count_total', label: 'Toplam Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'invoice_count_total', label: 'Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true }, { name: 'invoice_count_total', label: 'Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
@@ -1368,17 +1368,52 @@ const summaryCards = computed(() => [
{ key: 'risk_cost', label: 'Risk Maliyeti', value: formatMoney(summary.value.risk_cost_usd, 'USD') } { key: 'risk_cost', label: 'Risk Maliyeti', value: formatMoney(summary.value.risk_cost_usd, 'USD') }
]) ])
const idleRows = computed(() => rows.value const idleRows = computed(() => {
.map(row => ({ const variants = new Map()
...row, for (const row of rows.value) {
idle_cost_usd: Number(row.stock_qty || 0) * Number(row.cost_price_usd || 0) const key = `${row.product_code || ''}|${row.color_code || ''}|${row.yaka_kodu || ''}`
})) const current = variants.get(key)
.filter(row => Number(row.stock_qty || 0) > 0 && ( if (!current) {
row.performance_bucket === 'STOK_RISKI' || variants.set(key, {
Number(row.sales_qty_90d || 0) === 0 || ...row,
Number(row.stock_days_90d || 0) > 180 market_key: row.market_key && row.market_key !== 'STOK' ? row.market_key : '',
)) sales_qty_90d: Number(row.sales_qty_90d || 0),
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0))) sales_usd_90d: Number(row.sales_usd_90d || 0),
gross_profit_usd_90d: Number(row.gross_profit_usd_90d || 0),
customer_count_90d: Number(row.customer_count_90d || 0),
market_count_90d: row.market_key && row.market_key !== 'STOK' ? 1 : 0,
has_stock_risk: row.performance_bucket === 'STOK_RISKI'
})
continue
}
current.market_key = distinctTextSummary(current.market_key, row.market_key)
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)
current.customer_count_90d += Number(row.customer_count_90d || 0)
current.market_count_90d += row.market_key && row.market_key !== 'STOK' ? 1 : 0
current.has_stock_risk = current.has_stock_risk || row.performance_bucket === 'STOK_RISKI'
}
return Array.from(variants.values())
.map(row => {
const stockQty = Number(row.stock_qty || 0)
const salesQty90 = Number(row.sales_qty_90d || 0)
const stockDays90 = salesQty90 > 0 ? stockQty / (salesQty90 / 90) : 0
return {
...row,
stock_days_90d: stockDays90,
idle_cost_usd: stockQty * Number(row.cost_price_usd || 0),
performance_bucket: row.has_stock_risk ? 'STOK_RISKI' : row.performance_bucket
}
})
.filter(row => Number(row.stock_qty || 0) > 0 && (
row.performance_bucket === 'STOK_RISKI' ||
Number(row.sales_qty_90d || 0) === 0 ||
Number(row.stock_days_90d || 0) > 180
))
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0))
})
const visibleOrderAnalysisRows = computed(() => { const visibleOrderAnalysisRows = computed(() => {
if (orderAnalysisMode.value === 'market') return orderMarketRows.value if (orderAnalysisMode.value === 'market') return orderMarketRows.value
@@ -1405,7 +1440,8 @@ const performanceCards = computed(() => {
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(row.cost_price_usd, 'USD') }, { key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(row.cost_price_usd, 'USD') },
{ key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(row.unit_profit_base_90d, 'USD') }, { key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(row.unit_profit_base_90d, 'USD') },
{ key: 'profitCost', label: 'P.Başı Çıplak K/Z', value: formatMoney(row.unit_profit_cost_90d, 'USD') }, { key: 'profitCost', label: 'P.Başı Çıplak K/Z', value: formatMoney(row.unit_profit_cost_90d, 'USD') },
{ key: 'margin', label: '90G Marj', value: formatPercent(row.gross_margin_90d) }, { key: 'marginBase', label: '90G Taban Marj', value: formatPercent(row.gross_margin_base_90d) },
{ key: 'marginCost', label: '90G Çıplak Marj', value: formatPercent(row.gross_margin_cost_90d) },
{ key: 'bucket', label: 'Durum', value: bucketLabel(row.performance_bucket) } { key: 'bucket', label: 'Durum', value: bucketLabel(row.performance_bucket) }
] ]
}) })
@@ -1488,21 +1524,33 @@ function appendGroupRows (out, sourceRows, level, parentKeys) {
.sort((a, b) => a[0].localeCompare(b[0], 'tr')) .sort((a, b) => a[0].localeCompare(b[0], 'tr'))
.forEach(([value, groupRows]) => { .forEach(([value, groupRows]) => {
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|') const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
out.push(makeGroupRow(key, level, `${groupDef.label}: ${value}`, groupRows)) out.push(makeGroupRow(key, level, groupDef, value, groupRows))
if (isGroupExpanded(key)) { if (isGroupExpanded(key)) {
appendGroupRows(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`]) appendGroupRows(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`])
} }
}) })
} }
function makeGroupRow (key, level, label, groupRows) { function makeGroupRow (key, level, groupDef, value, groupRows) {
return { return {
__group: true, __group: true,
row_key: `group|${key}`, row_key: `group|${key}`,
key, key,
level, level,
label, group_field: groupDef.key,
group_value: value,
label: `${groupDef.label}: ${value}`,
count: groupRows.length, count: groupRows.length,
product_code: groupDef.key === 'product_code' ? value : distinctSummary(groupRows, 'product_code'),
color_code: groupDef.key === 'color_code' ? value : distinctSummary(groupRows, 'color_code'),
yaka_kodu: groupDef.key === 'yaka_kodu' ? value : distinctSummary(groupRows, 'yaka_kodu'),
item_description: distinctSummary(groupRows, 'item_description'),
kategori: groupDef.key === 'kategori' ? value : distinctSummary(groupRows, 'kategori'),
urun_ilk_grubu: groupDef.key === 'urun_ilk_grubu' ? value : distinctSummary(groupRows, 'urun_ilk_grubu', 'yas_grubu'),
askili_yan: groupDef.key === 'askili_yan' ? value : distinctSummary(groupRows, 'askili_yan'),
urun_ana_grubu: groupDef.key === 'urun_ana_grubu' ? value : distinctSummary(groupRows, 'urun_ana_grubu', 'seri'),
urun_alt_grubu: groupDef.key === 'urun_alt_grubu' ? value : distinctSummary(groupRows, 'urun_alt_grubu'),
market_key: distinctSummary(groupRows, 'market_key'),
stock_qty: sumRows(groupRows, 'stock_qty'), stock_qty: sumRows(groupRows, 'stock_qty'),
sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'), sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'),
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'), sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
@@ -1514,10 +1562,24 @@ function makeGroupRow (key, level, label, groupRows) {
customer_count_90d: sumRows(groupRows, 'customer_count_90d'), customer_count_90d: sumRows(groupRows, 'customer_count_90d'),
avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'), avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'),
avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'), avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'),
avg_base_price_usd: averageRows(groupRows, 'base_price_usd'), base_price_usd: weightedAverageOrAverage(groupRows, 'base_price_usd', 'sales_qty_90d'),
avg_cost_price_usd: averageRows(groupRows, 'cost_price_usd'), cost_price_usd: weightedAverageOrAverage(groupRows, 'cost_price_usd', 'sales_qty_90d'),
unit_profit_base_90d: weightedAverageOrAverage(groupRows, 'unit_profit_base_90d', 'sales_qty_90d'),
unit_profit_cost_90d: weightedAverageOrAverage(groupRows, 'unit_profit_cost_90d', 'sales_qty_90d'),
unit_profit_base_180d: weightedAverageOrAverage(groupRows, 'unit_profit_base_180d', 'sales_qty_180d'),
unit_profit_cost_180d: weightedAverageOrAverage(groupRows, 'unit_profit_cost_180d', 'sales_qty_180d'),
gross_margin_base_90d: marginFromRows(groupRows, 'sales_usd_90d', 'sales_qty_90d', 'base_price_usd'),
gross_margin_cost_90d: marginFromRows(groupRows, 'sales_usd_90d', 'sales_qty_90d', 'cost_price_usd'),
gross_margin_base_180d: marginFromRows(groupRows, 'sales_usd_180d', 'sales_qty_180d', 'base_price_usd'),
gross_margin_cost_180d: marginFromRows(groupRows, 'sales_usd_180d', 'sales_qty_180d', 'cost_price_usd'),
gross_margin_90d: ratio(sumRows(groupRows, 'gross_profit_usd_90d'), sumRows(groupRows, 'sales_usd_90d')), gross_margin_90d: ratio(sumRows(groupRows, 'gross_profit_usd_90d'), sumRows(groupRows, 'sales_usd_90d')),
gross_margin_180d: ratio(sumRows(groupRows, 'gross_profit_usd_180d'), sumRows(groupRows, 'sales_usd_180d')) gross_margin_180d: ratio(sumRows(groupRows, 'gross_profit_usd_180d'), sumRows(groupRows, 'sales_usd_180d')),
stock_days_90d: weightedAverageOrAverage(groupRows, 'stock_days_90d', 'stock_qty'),
stock_days_180d: weightedAverageOrAverage(groupRows, 'stock_days_180d', 'stock_qty'),
sales_index_90d: averageRows(groupRows, 'sales_index_90d'),
performance_score: weightedAverageOrAverage(groupRows, 'performance_score', 'sales_qty_90d'),
performance_bucket: dominantValue(groupRows, 'performance_bucket'),
recommendation: `${formatNumber(groupRows.length, 0)} satır`
} }
} }
@@ -1534,6 +1596,35 @@ function distinctCount (sourceRows, field) {
return values.size return values.size
} }
function distinctSummary (sourceRows, field, fallbackField) {
const values = new Set()
for (const row of sourceRows) {
const value = String(row[field] || (fallbackField ? row[fallbackField] : '') || '').trim()
if (value && value !== '-') values.add(value)
if (values.size > 1) return `${values.size} farklı`
}
return Array.from(values)[0] || ''
}
function distinctTextSummary (current, next) {
const left = String(current || '').trim()
const right = String(next || '').trim()
if (!right || right === 'STOK') return left
if (!left) return right
if (left === right || left === 'Çoklu') return left
return 'Çoklu'
}
function dominantValue (sourceRows, field) {
const counts = new Map()
for (const row of sourceRows) {
const value = String(row[field] || '').trim()
if (!value) continue
counts.set(value, (counts.get(value) || 0) + 1)
}
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || ''
}
function ratio (amount, base) { function ratio (amount, base) {
const divisor = Number(base || 0) const divisor = Number(base || 0)
if (!divisor) return 0 if (!divisor) return 0
@@ -1552,6 +1643,35 @@ function weightedAverage (sourceRows, amountField, qtyField) {
return sumRows(sourceRows, amountField) / qty return sumRows(sourceRows, amountField) / qty
} }
function weightedAverageOrAverage (sourceRows, valueField, qtyField) {
const weightedRows = sourceRows
.map(row => ({
value: Number(row[valueField] || 0),
qty: Number(row[qtyField] || 0)
}))
.filter(row => Number.isFinite(row.value) && Number.isFinite(row.qty) && row.qty > 0)
const qty = weightedRows.reduce((sum, row) => sum + row.qty, 0)
if (qty > 0) {
return weightedRows.reduce((sum, row) => sum + row.value * row.qty, 0) / qty
}
return averageRows(sourceRows, valueField)
}
function marginFromSalesCost (salesUSD, qty, unitCost) {
const sales = Number(salesUSD || 0)
if (!sales) return 0
return (sales - (Number(qty || 0) * Number(unitCost || 0))) / sales
}
function marginFromRows (sourceRows, salesField, qtyField, unitCostField) {
const sales = sumRows(sourceRows, salesField)
if (!sales) return 0
const costAmount = sourceRows.reduce((sum, row) => {
return sum + Number(row[qtyField] || 0) * Number(row[unitCostField] || 0)
}, 0)
return (sales - costAmount) / sales
}
function normalizeGroupValue (value) { function normalizeGroupValue (value) {
const text = String(value || '').trim() const text = String(value || '').trim()
return text || '-' return text || '-'
@@ -1568,6 +1688,40 @@ function toggleGroup (key) {
} }
} }
function groupLabelColumnName (row) {
return row?.group_field || 'product_code'
}
function groupCellClass (name) {
return ['stock_qty', 'sales_qty_90d', 'sales_qty_180d', 'sales_usd_90d', 'sales_usd_180d', 'stock_days_90d', 'stock_days_180d', 'avg_price_usd_90d', 'avg_price_usd_180d', 'base_price_usd', 'cost_price_usd', 'unit_profit_base_90d', 'unit_profit_cost_90d', 'unit_profit_base_180d', 'unit_profit_cost_180d', 'gross_profit_usd_90d', 'gross_profit_usd_180d', 'gross_margin_base_90d', 'gross_margin_cost_90d', 'gross_margin_base_180d', 'gross_margin_cost_180d', 'gross_margin_90d', 'gross_margin_180d', 'market_count_90d', 'customer_count_90d', 'sales_index_90d', 'performance_score'].includes(name)
? 'text-right'
: ''
}
function formatGroupCell (row, name) {
if (name === 'image') return ''
if (name === 'recommendation') return row.recommendation || ''
return formatProductCell(row, name)
}
function withProductMargins (row) {
return {
...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)
}
}
function withGeneralMargins (row) {
return {
...row,
gross_margin_base_total: marginFromSalesCost(row?.sales_usd_total, row?.sales_qty_total, row?.base_price_usd),
gross_margin_cost_total: marginFromSalesCost(row?.sales_usd_total, row?.sales_qty_total, row?.cost_price_usd)
}
}
function filterKey (tableKey, name) { function filterKey (tableKey, name) {
return `${tableKey}:${name}` return `${tableKey}:${name}`
} }
@@ -1690,6 +1844,12 @@ function formatProductCell (row, name) {
case 'gross_profit_usd_90d': case 'gross_profit_usd_90d':
case 'gross_profit_usd_180d': case 'gross_profit_usd_180d':
return formatMoney(row[name], 'USD') return formatMoney(row[name], 'USD')
case 'gross_margin_base_90d':
case 'gross_margin_cost_90d':
case 'gross_margin_base_180d':
case 'gross_margin_cost_180d':
case 'gross_margin_base_total':
case 'gross_margin_cost_total':
case 'gross_margin_90d': case 'gross_margin_90d':
case 'gross_margin_180d': case 'gross_margin_180d':
return formatPercent(row[name]) return formatPercent(row[name])
@@ -1707,10 +1867,10 @@ function normalizeRow (row) {
const color = String(row?.color_code || '').trim() const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim() const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim() const market = String(row?.market_key || '').trim()
return { return withProductMargins({
...row, ...row,
row_key: `${product}|${color}|${yaka}|${market}` row_key: `${product}|${color}|${yaka}|${market}`
} })
} }
function normalizeGeneralRow (row) { function normalizeGeneralRow (row) {
@@ -1718,10 +1878,10 @@ function normalizeGeneralRow (row) {
const color = String(row?.color_code || '').trim() const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim() const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim() const market = String(row?.market_key || '').trim()
return { return withGeneralMargins({
...row, ...row,
row_key: `general|${product}|${color}|${yaka}|${market}` row_key: `general|${product}|${color}|${yaka}|${market}`
} })
} }
function normalizeOrderAnalysisRow (row) { function normalizeOrderAnalysisRow (row) {
@@ -1915,8 +2075,8 @@ async function reload () {
api.get('/pricing/product-performance/summary', { timeout: 60000 }), api.get('/pricing/product-performance/summary', { timeout: 60000 }),
api.get('/pricing/product-performance', { api.get('/pricing/product-performance', {
params: { params: {
page: pagination.value.page, page: 1,
limit: pagination.value.rowsPerPage, limit: productKpiFetchLimit,
sort_by: pagination.value.sortBy || undefined, sort_by: pagination.value.sortBy || undefined,
descending: pagination.value.descending ? 'true' : 'false' descending: pagination.value.descending ? 'true' : 'false'
}, },
@@ -2060,11 +2220,28 @@ onMounted(reload)
max-height: calc(100vh - 320px); max-height: calc(100vh - 320px);
} }
.performance-table :deep(.q-table) {
border-collapse: separate;
border-spacing: 0;
}
.performance-table :deep(.q-table th),
.performance-table :deep(.q-table td) {
border-right: 1px solid #d5dce5;
border-bottom: 1px solid #d5dce5;
}
.performance-table :deep(.q-table th:first-child),
.performance-table :deep(.q-table td:first-child) {
border-left: 1px solid #d5dce5;
}
.performance-table :deep(th) { .performance-table :deep(th) {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
background: #fff; background: #f8fbff;
border-top: 1px solid #d5dce5;
} }
.filterable-header-cell { .filterable-header-cell {
@@ -2128,18 +2305,58 @@ onMounted(reload)
} }
.group-row td { .group-row td {
background: #eef2f6; background: #eaf4ff;
font-weight: 600; font-weight: 600;
color: #17324f;
border-color: #c3d4e6;
white-space: nowrap;
}
.group-row-level-0 td {
background: #1565c0;
color: #fff;
border-color: #0d47a1;
}
.group-row-level-1 td {
background: #2f7fd0;
color: #fff;
border-color: #1565c0;
}
.group-row-level-2 td {
background: #6da9df;
color: #0c2f52;
border-color: #3c83c7;
}
.group-row-level-3 td {
background: #a9cdef;
color: #12385c;
border-color: #78addb;
}
.group-row-level-4 td {
background: #d1e5f8;
color: #17324f;
border-color: #9fc5e8;
}
.group-row-level-5 td {
background: #eaf4ff;
color: #17324f;
border-color: #c3d4e6;
}
.group-cell-label {
display: flex;
align-items: center;
min-width: 180px;
} }
.group-title { .group-title {
margin-left: 4px; margin-left: 4px;
} white-space: normal;
.group-meta {
margin-left: 12px;
color: #5f6b7a;
font-weight: 500;
} }
.product-image-cell { .product-image-cell {