ui: update ProductPerformanceProfitability page to enhance table views and tabs management
This commit is contained in:
@@ -670,8 +670,10 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc
|
||||
return nil, 0, err
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
page := f.Page
|
||||
if page <= 0 {
|
||||
@@ -741,6 +743,25 @@ KPI AS (
|
||||
FROM mk_product_performance_kpi_daily
|
||||
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 (
|
||||
SELECT
|
||||
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='STOK_RISKI'),
|
||||
COUNT(*) FILTER (WHERE performance_bucket='STOKSUZ_TALEP'),
|
||||
COALESCE(SUM(stock_qty),0),
|
||||
COALESCE(SUM(stock_qty * cost_price_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.total_stock),0),
|
||||
COALESCE(MAX(StockTotals.stock_cost_usd),0),
|
||||
COALESCE(MAX(StockTotals.risk_cost_usd),0),
|
||||
COALESCE(SUM(sales_qty_90d),0),
|
||||
COALESCE(SUM(sales_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'),'')
|
||||
FROM KPI
|
||||
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)
|
||||
return s, err
|
||||
}
|
||||
@@ -2007,10 +2029,22 @@ func ListProductPerformanceStockSizes(ctx context.Context, productCode, colorCod
|
||||
return nil, fmt.Errorf("mssql db nil")
|
||||
}
|
||||
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
|
||||
SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))),
|
||||
StockQty = SUM(S.InventoryQty1)
|
||||
FROM StockWithCost S WITH(NOLOCK)
|
||||
INNER JOIN ActiveWarehouses W
|
||||
ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
|
||||
WHERE S.ItemTypeCode = 1
|
||||
AND LTRIM(RTRIM(S.ItemCode)) = @p1
|
||||
AND LTRIM(RTRIM(ISNULL(S.ColorCode, ''))) = @p2
|
||||
@@ -2093,6 +2127,10 @@ func productPerformanceOrderBy(sortBy string, desc bool) string {
|
||||
"unit_profit_cost_90d": "unit_profit_cost_90d",
|
||||
"unit_profit_base_180d": "unit_profit_base_180d",
|
||||
"unit_profit_cost_180d": "unit_profit_cost_180d",
|
||||
"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_cost_90d": "gross_margin_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)",
|
||||
"gross_margin_cost_180d": "gross_margin_180d",
|
||||
"gross_margin_90d": "gross_margin_90d",
|
||||
"gross_margin_180d": "gross_margin_180d",
|
||||
"market_count_90d": "market_count_90d",
|
||||
|
||||
@@ -216,10 +216,11 @@ func productPerformanceStockSQL() string {
|
||||
;WITH ActiveWarehouses AS (
|
||||
SELECT WarehouseCode
|
||||
FROM (VALUES
|
||||
('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'),
|
||||
('100'),('1-0-28'),('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'),
|
||||
('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'),('1-0-33'),('101'),
|
||||
('1-014'),('1-0-49'),('1-0-36')
|
||||
('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)
|
||||
),
|
||||
Raw AS (
|
||||
|
||||
@@ -449,8 +449,17 @@
|
||||
</template>
|
||||
|
||||
<template #body="props">
|
||||
<q-tr v-if="props.row.__group" :props="props" class="group-row">
|
||||
<q-td :colspan="columns.length" :style="{ paddingLeft: `${8 + props.row.level * 18}px` }">
|
||||
<q-tr
|
||||
v-if="props.row.__group"
|
||||
:props="props"
|
||||
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
|
||||
>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
|
||||
<div
|
||||
v-if="col.name === groupLabelColumnName(props.row)"
|
||||
class="group-cell-label"
|
||||
:style="{ paddingLeft: `${props.row.level * 18}px` }"
|
||||
>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
@@ -460,21 +469,8 @@
|
||||
@click.stop="toggleGroup(props.row.key)"
|
||||
/>
|
||||
<span class="group-title">{{ props.row.label }}</span>
|
||||
<span class="group-meta">
|
||||
{{ formatNumber(props.row.count, 0) }} satır ·
|
||||
Stok {{ formatNumber(props.row.stock_qty, 0) }} ·
|
||||
90G {{ formatNumber(props.row.sales_qty_90d, 0) }} ·
|
||||
180G {{ formatNumber(props.row.sales_qty_180d, 0) }} ·
|
||||
Piyasa {{ formatNumber(props.row.market_count_90d, 0) }} ·
|
||||
Müşteri {{ formatNumber(props.row.customer_count_90d, 0) }} ·
|
||||
Ort.Satış {{ formatMoney(props.row.avg_price_usd_90d, 'USD') }} ·
|
||||
Taban {{ formatMoney(props.row.avg_base_price_usd, 'USD') }} ·
|
||||
Çıplak {{ formatMoney(props.row.avg_cost_price_usd, 'USD') }} ·
|
||||
90G K/Z {{ formatMoney(props.row.gross_profit_usd_90d, 'USD') }} ·
|
||||
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>
|
||||
</div>
|
||||
<span v-else>{{ formatGroupCell(props.row, col.name) }}</span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)">
|
||||
@@ -1059,10 +1055,11 @@ const detailStockSizes = ref([])
|
||||
const columnFilters = reactive({})
|
||||
const columnFilterSearch = reactive({})
|
||||
const expandedGroups = ref({})
|
||||
const productKpiFetchLimit = 50000
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
rowsPerPage: 500,
|
||||
rowsPerPage: 100,
|
||||
rowsNumber: 0,
|
||||
sortBy: 'performance_score',
|
||||
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: '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_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true },
|
||||
{ name: 'gross_margin_180d', label: '180G Marj', field: 'gross_margin_180d', 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_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: '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 },
|
||||
@@ -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_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_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: '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 },
|
||||
@@ -1368,17 +1368,52 @@ const summaryCards = computed(() => [
|
||||
{ key: 'risk_cost', label: 'Risk Maliyeti', value: formatMoney(summary.value.risk_cost_usd, 'USD') }
|
||||
])
|
||||
|
||||
const idleRows = computed(() => rows.value
|
||||
.map(row => ({
|
||||
const idleRows = computed(() => {
|
||||
const variants = new Map()
|
||||
for (const row of rows.value) {
|
||||
const key = `${row.product_code || ''}|${row.color_code || ''}|${row.yaka_kodu || ''}`
|
||||
const current = variants.get(key)
|
||||
if (!current) {
|
||||
variants.set(key, {
|
||||
...row,
|
||||
idle_cost_usd: Number(row.stock_qty || 0) * Number(row.cost_price_usd || 0)
|
||||
}))
|
||||
market_key: row.market_key && row.market_key !== 'STOK' ? row.market_key : '',
|
||||
sales_qty_90d: Number(row.sales_qty_90d || 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)))
|
||||
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0))
|
||||
})
|
||||
|
||||
const visibleOrderAnalysisRows = computed(() => {
|
||||
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: '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: '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) }
|
||||
]
|
||||
})
|
||||
@@ -1488,21 +1524,33 @@ function appendGroupRows (out, sourceRows, level, parentKeys) {
|
||||
.sort((a, b) => a[0].localeCompare(b[0], 'tr'))
|
||||
.forEach(([value, groupRows]) => {
|
||||
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)) {
|
||||
appendGroupRows(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeGroupRow (key, level, label, groupRows) {
|
||||
function makeGroupRow (key, level, groupDef, value, groupRows) {
|
||||
return {
|
||||
__group: true,
|
||||
row_key: `group|${key}`,
|
||||
key,
|
||||
level,
|
||||
label,
|
||||
group_field: groupDef.key,
|
||||
group_value: value,
|
||||
label: `${groupDef.label}: ${value}`,
|
||||
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'),
|
||||
sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'),
|
||||
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'),
|
||||
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_base_price_usd: averageRows(groupRows, 'base_price_usd'),
|
||||
avg_cost_price_usd: averageRows(groupRows, 'cost_price_usd'),
|
||||
base_price_usd: weightedAverageOrAverage(groupRows, 'base_price_usd', 'sales_qty_90d'),
|
||||
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_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
|
||||
}
|
||||
|
||||
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) {
|
||||
const divisor = Number(base || 0)
|
||||
if (!divisor) return 0
|
||||
@@ -1552,6 +1643,35 @@ function weightedAverage (sourceRows, amountField, qtyField) {
|
||||
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) {
|
||||
const text = String(value || '').trim()
|
||||
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) {
|
||||
return `${tableKey}:${name}`
|
||||
}
|
||||
@@ -1690,6 +1844,12 @@ function formatProductCell (row, name) {
|
||||
case 'gross_profit_usd_90d':
|
||||
case 'gross_profit_usd_180d':
|
||||
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_180d':
|
||||
return formatPercent(row[name])
|
||||
@@ -1707,10 +1867,10 @@ function normalizeRow (row) {
|
||||
const color = String(row?.color_code || '').trim()
|
||||
const yaka = String(row?.yaka_kodu || '').trim()
|
||||
const market = String(row?.market_key || '').trim()
|
||||
return {
|
||||
return withProductMargins({
|
||||
...row,
|
||||
row_key: `${product}|${color}|${yaka}|${market}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeGeneralRow (row) {
|
||||
@@ -1718,10 +1878,10 @@ function normalizeGeneralRow (row) {
|
||||
const color = String(row?.color_code || '').trim()
|
||||
const yaka = String(row?.yaka_kodu || '').trim()
|
||||
const market = String(row?.market_key || '').trim()
|
||||
return {
|
||||
return withGeneralMargins({
|
||||
...row,
|
||||
row_key: `general|${product}|${color}|${yaka}|${market}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeOrderAnalysisRow (row) {
|
||||
@@ -1915,8 +2075,8 @@ async function reload () {
|
||||
api.get('/pricing/product-performance/summary', { timeout: 60000 }),
|
||||
api.get('/pricing/product-performance', {
|
||||
params: {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.rowsPerPage,
|
||||
page: 1,
|
||||
limit: productKpiFetchLimit,
|
||||
sort_by: pagination.value.sortBy || undefined,
|
||||
descending: pagination.value.descending ? 'true' : 'false'
|
||||
},
|
||||
@@ -2060,11 +2220,28 @@ onMounted(reload)
|
||||
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) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
background: #f8fbff;
|
||||
border-top: 1px solid #d5dce5;
|
||||
}
|
||||
|
||||
.filterable-header-cell {
|
||||
@@ -2128,18 +2305,58 @@ onMounted(reload)
|
||||
}
|
||||
|
||||
.group-row td {
|
||||
background: #eef2f6;
|
||||
background: #eaf4ff;
|
||||
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 {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.group-meta {
|
||||
margin-left: 12px;
|
||||
color: #5f6b7a;
|
||||
font-weight: 500;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.product-image-cell {
|
||||
|
||||
Reference in New Issue
Block a user