Compare commits

...
2 Commits
Author SHA1 Message Date
M_Kececi 9e22cc5ec5 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	svc/queries/product_performance_sql.go
#	ui/src/pages/ProductPerformanceProfitability.vue
2026-07-03 20:10:05 +03:00
M_Kececi 5e2a85b98e product performance grouped kpi improvements 2026-07-03 20:09:39 +03:00
5 changed files with 173 additions and 22 deletions
+14 -2
View File
@@ -3123,6 +3123,7 @@ type ProductPerformanceGroupedRequest struct {
GroupLevels []string
ExpandedKeys map[string]bool
Limit int
MainGroup string
}
func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) {
@@ -3154,17 +3155,26 @@ type productPerformanceSQLGroupFilter struct {
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" {
if mode != "products" && mode != "product_detail" && 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, 0, []string{"tab:" + mode}, nil, req.ExpandedKeys, req.Limit)
filters := productPerformanceGroupedBaseFilters(req)
err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, 0, []string{"tab:" + mode}, filters, req.ExpandedKeys, req.Limit)
return out, true, err
}
func productPerformanceGroupedBaseFilters(req ProductPerformanceGroupedRequest) []productPerformanceSQLGroupFilter {
mainGroup := strings.TrimSpace(req.MainGroup)
if strings.TrimSpace(req.Mode) != "product_detail" || mainGroup == "" {
return nil
}
return []productPerformanceSQLGroupFilter{{Field: "urun_ana_grubu", Value: mainGroup}}
}
func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, visualLevel int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, limit int) error {
if level >= len(levels) {
rows, err := queryProductPerformanceSQLLeafRows(ctx, pg, mode, filters, limit)
@@ -3948,6 +3958,8 @@ func defaultProductPerformanceGroupLevels(mode string) []string {
switch mode {
case "sales_color_yaka_market_customer":
return []string{"color_code", "yaka_kodu", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "country", "market_key", "customer_segment", "customer_code", "customer_name"}
case "product_detail":
return []string{"urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu", "market_key"}
case "idle":
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
case "sales_product_country_segment_market_customer":
+3
View File
@@ -509,6 +509,9 @@ Scope AS (
PriceDim AS (
SELECT
product_code,
COALESCE(urun_ilk_grubu, '') AS urun_ilk_grubu,
COALESCE(urun_ana_grubu, '') AS urun_ana_grubu,
COALESCE(urun_alt_grubu, '') AS urun_alt_grubu,
CASE
WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd < base_price_usd THEN base_price_usd
ELSE cost_price_usd
+6
View File
@@ -269,6 +269,7 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
defer cancel()
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
mainGroup := strings.TrimSpace(r.URL.Query().Get("urun_ana_grubu"))
groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels"))
limit := intQuery(r, "limit", 50000)
expanded := map[string]bool{}
@@ -278,6 +279,7 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
GroupLevels []string `json:"group_levels"`
ExpandedKeys []string `json:"expanded_keys"`
Limit int `json:"limit"`
MainGroup string `json:"urun_ana_grubu"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest)
@@ -286,6 +288,9 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
if strings.TrimSpace(body.Mode) != "" {
mode = strings.TrimSpace(body.Mode)
}
if strings.TrimSpace(body.MainGroup) != "" {
mainGroup = strings.TrimSpace(body.MainGroup)
}
if len(body.GroupLevels) > 0 {
groupLevels = body.GroupLevels
}
@@ -312,6 +317,7 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
GroupLevels: groupLevels,
ExpandedKeys: expanded,
Limit: limit,
MainGroup: mainGroup,
})
if err != nil {
http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
+146 -19
View File
@@ -86,6 +86,20 @@
</button>
</div>
<div v-if="activeTab === 'product_detail'" class="main-group-bar q-mb-xs">
<button
v-for="option in detailMainGroupOptions"
:key="option.value"
type="button"
:class="['main-group-button', { active: selectedDetailMainGroup === option.value }]"
:disabled="pageBusy"
@click="selectedDetailMainGroup = option.value"
>
<span>{{ option.label }}</span>
<small>{{ formatNumber(option.stock_qty, 0) }} stok</small>
</button>
</div>
<q-table
v-if="activeTab === 'general'"
flat
@@ -525,13 +539,13 @@
</q-table>
<q-table
v-else-if="activeTab === 'products'"
v-else-if="isProductKpiTab"
flat
bordered
row-key="row_key"
class="performance-table product-breakdown-table bg-white"
:rows="displayProductTableRows"
:columns="productColumns"
:rows="displayProductKpiTableRows"
:columns="activeProductColumns"
:loading="loading || backendGroupedLoading"
:pagination="pagination"
:sort-method="sortProductGroupedTableRows"
@@ -549,17 +563,17 @@
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter('products', props.col.name) ? 'primary' : 'grey-7'"
:color="hasColumnFilter(activeProductTableKey, props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter('products', props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('products', props.col.name) }}
<q-badge v-if="hasColumnFilter(activeProductTableKey, props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue(activeProductTableKey, props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey('products', props.col.name)]"
v-model="columnFilterSearch[filterKey(activeProductTableKey, props.col.name)]"
dense
outlined
clearable
@@ -567,13 +581,13 @@
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions('products', props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter('products', props.col.name)" />
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions(activeProductTableKey, props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter(activeProductTableKey, props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions('products', props.col.name).length > 0"
v-if="columnFilterOptions(activeProductTableKey, props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions('products', props.col.name)"
:items="columnFilterOptions(activeProductTableKey, props.col.name)"
:virtual-scroll-item-size="32"
separator
>
@@ -583,14 +597,14 @@
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue('products', props.col.name, option.value)"
@click="toggleColumnFilterValue(activeProductTableKey, props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected('products', props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('products', props.col.name, option.value)"
:model-value="isColumnFilterValueSelected(activeProductTableKey, props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue(activeProductTableKey, props.col.name, option.value)"
@click.stop
/>
</q-item-section>
@@ -1521,6 +1535,7 @@ const summary = ref({})
const activeTab = ref('products')
const customerBreakdown = ref('market_customer')
const selectedPeriods = ref(['90', '180', '360', 'total'])
const selectedDetailMainGroup = ref('')
const imageUrlByKey = ref({})
const imageListByKey = ref({})
const imageDialog = ref(false)
@@ -1549,6 +1564,7 @@ const expandedGroups = ref({})
const lastManualExpandedGroupKey = ref('')
const selectedExpandLevelKeysByTab = reactive({
products: ['urun_ilk_grubu', 'askili_yan', 'kategori', 'urun_ana_grubu', 'urun_alt_grubu'],
product_detail: ['urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'color_code', 'yaka_kodu'],
sales_color_yaka_market_customer: ['color_code', 'yaka_kodu', 'urun_ilk_grubu', 'askili_yan'],
idle: ['urun_ilk_grubu', 'askili_yan', 'kategori', 'urun_ana_grubu', 'urun_alt_grubu'],
sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
@@ -1561,7 +1577,8 @@ const productKpiFetchLimit = 50000
const reportFetchLimit = 50000
const maxBulkExpandKeys = 1200
const performanceTabs = [
{ name: 'products', icon: 'inventory_2', label: 'Ürün KPI' },
{ name: 'products', icon: 'dashboard', label: 'Genel Özet KPI' },
{ name: 'product_detail', icon: 'category', label: 'Detay 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' },
@@ -1842,6 +1859,10 @@ function reorderColumnsInPlace (targetColumns) {
}
const productColumns = computed(() => orderedMetricColumns(columns.filter(visiblePeriodColumn)))
const productDetailColumns = computed(() => orderedMetricColumns(columns
.filter(col => !['urun_ilk_grubu', 'askili_yan', 'kategori'].includes(col.name))
.filter(visiblePeriodColumn)))
const activeProductColumns = computed(() => activeTab.value === 'product_detail' ? productDetailColumns.value : productColumns.value)
const generalColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
@@ -2386,6 +2407,14 @@ const groupLevels = [
const tabGroupLevels = {
products: groupLevels,
product_detail: [
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' },
{ key: 'yaka_kodu', label: 'Yaka' },
{ key: 'market_key', label: 'Piyasa' }
],
sales_color_yaka_market_customer: [
{ key: 'color_code', label: 'Renk' },
{ key: 'yaka_kodu', label: 'Yaka' },
@@ -2507,6 +2536,7 @@ const autoExpandThroughLevel = computed(() => {
const tableFilterSourceMap = computed(() => ({
products: { rows: rows.value, columns: productColumns.value },
product_detail: { rows: detailProductRows.value, columns: productDetailColumns.value },
general: { rows: generalRows.value, columns: generalColumns },
order_product_customers: { rows: orderProductCustomerRows.value, columns: orderProductCustomerColumns },
order_market_details: { rows: orderMarketDetailRows.value, columns: orderMarketDetailColumns },
@@ -2521,6 +2551,14 @@ const tableFilterSourceMap = computed(() => ({
}))
const filteredProductRows = computed(() => filterRowsForTable('products', rows.value, productColumns.value))
const isProductKpiTab = computed(() => activeTab.value === 'products' || activeTab.value === 'product_detail')
const activeProductTableKey = computed(() => activeTab.value === 'product_detail' ? 'product_detail' : 'products')
const detailProductRows = computed(() => {
const selected = String(selectedDetailMainGroup.value || '').trim()
if (!selected) return rows.value
return rows.value.filter(row => productMainGroupValue(row) === selected)
})
const filteredDetailProductRows = computed(() => filterRowsForTable('product_detail', detailProductRows.value, productDetailColumns.value))
const filteredGeneralRows = computed(() => filterRowsForTable('general', generalRows.value, generalColumns))
const filteredOrderProductCustomerRows = computed(() => filterRowsForTable('order_product_customers', orderProductCustomerRows.value, orderProductCustomerColumns))
const filteredOrderMarketDetailRows = computed(() => filterRowsForTable('order_market_details', orderMarketDetailRows.value, orderMarketDetailColumns))
@@ -2531,6 +2569,7 @@ const filteredCustomerRows = computed(() => filterRowsForTable('customers', cust
const filteredActiveSalesBreakdownRows = computed(() => filterRowsForTable(activeTab.value, salesBreakdownRows[activeTab.value] || [], visibleSalesBreakdownColumns.value))
const activeGroupSourceRows = computed(() => {
if (activeTab.value === 'products') return filteredProductRows.value
if (activeTab.value === 'product_detail') return filteredDetailProductRows.value
if (activeTab.value === 'idle') return filteredIdleRows.value
if (activeTab.value === 'order_product_customers') return filteredOrderProductCustomerRows.value
if (activeTab.value === 'order_market_details') return filteredOrderMarketDetailRows.value
@@ -2567,17 +2606,34 @@ const productTableRows = computed(() => {
appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels)
return out
})
const detailProductTableRows = computed(() => buildGroupedTableRows('product_detail', filteredDetailProductRows.value))
const idleTableRows = computed(() => buildGroupedTableRows('idle', filteredIdleRows.value))
const orderProductCustomerTableRows = computed(() => buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value))
const orderMarketDetailTableRows = computed(() => buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value))
const activeSalesBreakdownTableRows = computed(() => buildGroupedTableRows(activeTab.value, filteredActiveSalesBreakdownRows.value))
const displayProductTableRows = computed(() => backendRowsForTab('products', productTableRows.value))
const displayDetailProductTableRows = computed(() => backendRowsForTab('product_detail', detailProductTableRows.value))
const displayProductKpiTableRows = computed(() => activeTab.value === 'product_detail' ? displayDetailProductTableRows.value : displayProductTableRows.value)
const displayIdleTableRows = computed(() => backendRowsForTab('idle', idleTableRows.value))
const displayOrderProductCustomerTableRows = computed(() => backendRowsForTab('order_product_customers', orderProductCustomerTableRows.value))
const displayOrderMarketDetailTableRows = computed(() => backendRowsForTab('order_market_details', orderMarketDetailTableRows.value))
const displayActiveSalesBreakdownTableRows = computed(() => backendRowsForTab(activeTab.value, activeSalesBreakdownTableRows.value))
const detailMainGroupOptions = computed(() => {
const grouped = new Map()
for (const row of rows.value) {
const value = productMainGroupValue(row)
if (!value) continue
const current = grouped.get(value) || { value, label: value, stock_qty: 0, sales_qty_90d: 0 }
current.stock_qty += Number(row?.stock_qty || 0)
current.sales_qty_90d += Number(row?.sales_qty_90d || 0)
grouped.set(value, current)
}
return Array.from(grouped.values())
.sort((a, b) => Number(b.sales_qty_90d || 0) - Number(a.sales_qty_90d || 0) || a.label.localeCompare(b.label, 'tr'))
})
const productGroupKeys = computed(() => {
const keys = []
collectGroupKeys(keys, filteredProductRows.value, 0, ['tab:products'], groupLevels)
@@ -4000,6 +4056,7 @@ async function loadProductDetailRows (row) {
function backendGroupedSupportedTab (tabKey = activeTab.value) {
return tabKey === 'products' ||
tabKey === 'product_detail' ||
tabKey === 'idle' ||
tabKey === 'order_product_customers' ||
tabKey === 'order_market_details' ||
@@ -4059,7 +4116,10 @@ async function loadBackendGroupedRows () {
mode: tabKey,
groupLevels: activeGroupLevels.value.map(level => level.key),
expandedKeys: activeExpandedGroupKeys(),
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000
}
if (tabKey === 'product_detail') {
params.urunAnaGrubu = selectedDetailMainGroup.value
}
try {
const startedAt = performance.now()
@@ -4088,7 +4148,7 @@ function normalizeGroupedDisplayRow (tabKey, row) {
return withOrderPeriodScores(nextRow)
}
const next = withPeriodScores(nextRow)
if (tabKey !== 'products' && tabKey !== 'idle') {
if (tabKey !== 'products' && tabKey !== 'product_detail' && tabKey !== 'idle') {
next.customer_score_90d = Number(next.customer_score_90d || customerSalesPeriodScore(periodMetricSource(next, '90d')))
next.customer_score_180d = Number(next.customer_score_180d || customerSalesPeriodScore(periodMetricSource(next, '180d')))
next.customer_score_365d = Number(next.customer_score_365d || customerSalesPeriodScore(periodMetricSource(next, '365d')))
@@ -4276,9 +4336,33 @@ watch(activeSelectedExpandLevelKeys, () => {
scheduleLoadBackendGroupedRows()
})
watch(detailMainGroupOptions, options => {
if (!options.length) {
selectedDetailMainGroup.value = ''
return
}
if (!selectedDetailMainGroup.value || !options.some(option => option.value === selectedDetailMainGroup.value)) {
selectedDetailMainGroup.value = options[0].value
}
}, { immediate: true })
watch(selectedDetailMainGroup, () => {
if (activeTab.value === 'product_detail') {
expandedGroups.value = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith('tab:product_detail|'))
)
backendGroupedRows.value = {
...backendGroupedRows.value,
product_detail: []
}
scheduleLoadBackendGroupedRows()
}
})
watch(productTableRows, tableRows => {
if (activeTab.value !== 'products') return
const imageRows = tableRows
if (!isProductKpiTab.value) return
const sourceRows = activeTab.value === 'product_detail' ? displayDetailProductTableRows.value : tableRows
const imageRows = sourceRows
.filter(row => !row.__group || groupShowsImage(row))
.slice(0, 240)
void primeProductImages(imageRows)
@@ -4411,6 +4495,49 @@ onMounted(reload)
opacity: 0.62;
}
.main-group-bar {
display: flex;
gap: 6px;
overflow-x: auto;
padding: 6px;
border: 1px solid #d7dde5;
border-radius: 6px;
background: #ffffff;
}
.main-group-button {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 4px 10px;
border: 1px solid #c9d2dd;
border-radius: 6px;
background: #f7f9fb;
color: #26384c;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.main-group-button small {
color: #64748b;
font-size: 10px;
font-weight: 600;
}
.main-group-button.active {
border-color: #1d4ed8;
background: #dbeafe;
color: #173a75;
}
.main-group-button:disabled {
cursor: wait;
opacity: 0.62;
}
.metric-card {
min-height: 38px;
}
+4 -1
View File
@@ -65,6 +65,7 @@ export const useProductPerformanceStore = defineStore('product-performance-store
groupedCacheKey (params = {}) {
return JSON.stringify({
mode: String(params.mode || ''),
mainGroup: String(params.urunAnaGrubu || params.urun_ana_grubu || ''),
groupLevels: normalizedList(params.groupLevels),
expandedKeys: normalizedList(params.expandedKeys, true),
limit: Number(params.limit || 0)
@@ -104,13 +105,15 @@ export const useProductPerformanceStore = defineStore('product-performance-store
})
const request = api.post('/pricing/product-performance/grouped', {
mode: params.mode,
urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '',
group_levels: Array.isArray(params.groupLevels) ? params.groupLevels : String(params.groupLevels || '').split(',').filter(Boolean),
expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys : String(params.expandedKeys || '').split(',').filter(Boolean),
limit: params.limit
}, {
params: {
mode: params.mode,
limit: params.limit
limit: params.limit,
urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || ''
},
timeout: 90000
}).then(resp => {