product performance grouped kpi improvements
This commit is contained in:
@@ -994,6 +994,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceGroupedHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/grouped", "POST",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceGroupedHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/sales-details", "GET",
|
||||
"pricing", "view",
|
||||
|
||||
@@ -179,6 +179,11 @@ type ProductPerformanceOrderProductCustomerRow struct {
|
||||
ColorCode string `json:"color_code"`
|
||||
YakaKodu string `json:"yaka_kodu"`
|
||||
ItemDescription string `json:"item_description"`
|
||||
Kategori string `json:"kategori"`
|
||||
AskiliYan string `json:"askili_yan"`
|
||||
UrunIlkGrubu string `json:"urun_ilk_grubu"`
|
||||
UrunAnaGrubu string `json:"urun_ana_grubu"`
|
||||
UrunAltGrubu string `json:"urun_alt_grubu"`
|
||||
MarketKey string `json:"market_key"`
|
||||
CustomerCode string `json:"customer_code"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
@@ -211,6 +216,11 @@ type ProductPerformanceOrderMarketDetailRow struct {
|
||||
ColorCode string `json:"color_code"`
|
||||
YakaKodu string `json:"yaka_kodu"`
|
||||
ItemDescription string `json:"item_description"`
|
||||
Kategori string `json:"kategori"`
|
||||
AskiliYan string `json:"askili_yan"`
|
||||
UrunIlkGrubu string `json:"urun_ilk_grubu"`
|
||||
UrunAnaGrubu string `json:"urun_ana_grubu"`
|
||||
UrunAltGrubu string `json:"urun_alt_grubu"`
|
||||
OrderQty float64 `json:"order_qty"`
|
||||
OrderUSD float64 `json:"order_usd"`
|
||||
AvgOrderPriceUSD float64 `json:"avg_order_price_usd"`
|
||||
@@ -288,6 +298,11 @@ type ProductPerformanceSalesBreakdownRow struct {
|
||||
ColorCode string `json:"color_code"`
|
||||
YakaKodu string `json:"yaka_kodu"`
|
||||
ItemDescription string `json:"item_description"`
|
||||
Kategori string `json:"kategori"`
|
||||
AskiliYan string `json:"askili_yan"`
|
||||
UrunIlkGrubu string `json:"urun_ilk_grubu"`
|
||||
UrunAnaGrubu string `json:"urun_ana_grubu"`
|
||||
UrunAltGrubu string `json:"urun_alt_grubu"`
|
||||
MarketKey string `json:"market_key"`
|
||||
Country string `json:"country"`
|
||||
CustomerSegment string `json:"customer_segment"`
|
||||
@@ -325,6 +340,8 @@ type ProductPerformanceSalesBreakdownRow struct {
|
||||
GrossProfitCost365 float64 `json:"gross_profit_cost_usd_365d"`
|
||||
GrossMarginBase365 float64 `json:"gross_margin_base_365d"`
|
||||
GrossMarginCost365 float64 `json:"gross_margin_cost_365d"`
|
||||
CustomerCount365 int `json:"customer_count_365d"`
|
||||
InvoiceCount365 int `json:"invoice_count_365d"`
|
||||
CustomerCountTotal int `json:"customer_count_total"`
|
||||
InvoiceCountTotal int `json:"invoice_count_total"`
|
||||
SalesQtyTotal float64 `json:"sales_qty_total"`
|
||||
@@ -336,8 +353,17 @@ type ProductPerformanceSalesBreakdownRow struct {
|
||||
GrossProfitCostTotal float64 `json:"gross_profit_cost_usd_total"`
|
||||
GrossMarginBaseTotal float64 `json:"gross_margin_base_total"`
|
||||
GrossMarginCostTotal float64 `json:"gross_margin_cost_total"`
|
||||
StockQty float64 `json:"stock_qty"`
|
||||
StockTurnover90 float64 `json:"stock_turnover_90d"`
|
||||
StockTurnover180 float64 `json:"stock_turnover_180d"`
|
||||
StockTurnover365 float64 `json:"stock_turnover_365d"`
|
||||
StockTurnoverTotal float64 `json:"stock_turnover_total"`
|
||||
HasCost bool `json:"has_cost"`
|
||||
SalesIndex90 float64 `json:"sales_index_90d"`
|
||||
CustomerScore90 float64 `json:"customer_score_90d"`
|
||||
CustomerScore180 float64 `json:"customer_score_180d"`
|
||||
CustomerScore365 float64 `json:"customer_score_365d"`
|
||||
CustomerScoreTotal float64 `json:"customer_score_total"`
|
||||
PerformanceScore float64 `json:"performance_score"`
|
||||
PerformanceBucket string `json:"performance_bucket"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
|
||||
@@ -1073,8 +1073,10 @@ func ListProductPerformanceGeneral(ctx context.Context, pg *sql.DB, limit int) (
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 1000 {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
WITH Bounds AS (
|
||||
@@ -1350,8 +1352,10 @@ func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 1000 {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
|
||||
rows, err := db.MssqlDB.QueryContext(ctx, `
|
||||
@@ -1592,9 +1596,22 @@ ORDER BY SUM(o.AmountUSD) DESC, SUM(o.Qty) DESC, o.ProductCode, o.ColorCode, o.Y
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
row := &out[i]
|
||||
price := priceByProduct[row.ProductCode]
|
||||
attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
||||
if strings.TrimSpace(row.ItemDescription) == "" {
|
||||
row.ItemDescription = attr.itemDescription
|
||||
}
|
||||
row.Kategori = attr.kategori
|
||||
row.AskiliYan = attr.askiliYan
|
||||
row.UrunIlkGrubu = attr.urunIlkGrubu
|
||||
row.UrunAnaGrubu = attr.urunAnaGrubu
|
||||
row.UrunAltGrubu = attr.urunAltGrubu
|
||||
row.CostPriceUSD = price.cost
|
||||
row.BasePriceUSD = price.base
|
||||
row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
||||
@@ -1952,9 +1969,22 @@ ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
row := &out[i]
|
||||
price := priceByProduct[row.ProductCode]
|
||||
attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
||||
if strings.TrimSpace(row.ItemDescription) == "" {
|
||||
row.ItemDescription = attr.itemDescription
|
||||
}
|
||||
row.Kategori = attr.kategori
|
||||
row.AskiliYan = attr.askiliYan
|
||||
row.UrunIlkGrubu = attr.urunIlkGrubu
|
||||
row.UrunAnaGrubu = attr.urunAnaGrubu
|
||||
row.UrunAltGrubu = attr.urunAltGrubu
|
||||
row.CostPriceUSD = price.cost
|
||||
row.BasePriceUSD = price.base
|
||||
row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
||||
@@ -1977,8 +2007,10 @@ func ListProductPerformanceOrderMarketDetails(ctx context.Context, pg *sql.DB, l
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 1500 {
|
||||
if limit <= 0 {
|
||||
limit = 800
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
rows, err := db.MssqlDB.QueryContext(ctx, `
|
||||
WITH OpenOrderLines AS (
|
||||
@@ -2268,8 +2300,10 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 1000 {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
mode := strings.ToLower(strings.TrimSpace(breakdown))
|
||||
fields := map[string]string{
|
||||
@@ -2277,6 +2311,11 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
|
||||
"color_code": "s.color_code",
|
||||
"yaka_kodu": "s.yaka_kodu",
|
||||
"item_description": "MAX(s.item_description)",
|
||||
"kategori": "MAX(s.kategori)",
|
||||
"askili_yan": "MAX(s.askili_yan)",
|
||||
"urun_ilk_grubu": "MAX(s.urun_ilk_grubu)",
|
||||
"urun_ana_grubu": "MAX(s.urun_ana_grubu)",
|
||||
"urun_alt_grubu": "MAX(s.urun_alt_grubu)",
|
||||
"market_key": "s.market_key",
|
||||
"country": "s.customer_country",
|
||||
"customer_segment": "s.customer_segment",
|
||||
@@ -2287,21 +2326,37 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
|
||||
groupCols := []string{}
|
||||
switch mode {
|
||||
case "color_yaka_market_customer":
|
||||
selected["product_code"] = true
|
||||
selected["color_code"] = true
|
||||
selected["yaka_kodu"] = true
|
||||
selected["market_key"] = true
|
||||
selected["customer_code"] = true
|
||||
selected["customer_name"] = true
|
||||
groupCols = []string{"s.color_code", "s.yaka_kodu", "s.market_key", "s.customer_code"}
|
||||
case "product_country_segment_market_customer":
|
||||
selected["product_code"] = true
|
||||
selected["item_description"] = true
|
||||
selected["kategori"] = true
|
||||
selected["askili_yan"] = true
|
||||
selected["urun_ilk_grubu"] = true
|
||||
selected["urun_ana_grubu"] = true
|
||||
selected["urun_alt_grubu"] = true
|
||||
selected["country"] = true
|
||||
selected["customer_segment"] = true
|
||||
selected["market_key"] = true
|
||||
selected["customer_code"] = true
|
||||
selected["customer_name"] = true
|
||||
groupCols = []string{"s.product_code", "s.customer_country", "s.customer_segment", "s.market_key", "s.customer_code"}
|
||||
groupCols = []string{"s.color_code", "s.yaka_kodu", "s.askili_yan", "s.urun_ilk_grubu", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
|
||||
case "product_country_segment_market_customer":
|
||||
selected["product_code"] = true
|
||||
selected["item_description"] = true
|
||||
selected["kategori"] = true
|
||||
selected["askili_yan"] = true
|
||||
selected["urun_ilk_grubu"] = true
|
||||
selected["urun_ana_grubu"] = true
|
||||
selected["urun_alt_grubu"] = true
|
||||
selected["color_code"] = true
|
||||
selected["yaka_kodu"] = true
|
||||
selected["country"] = true
|
||||
selected["customer_segment"] = true
|
||||
selected["market_key"] = true
|
||||
selected["customer_code"] = true
|
||||
selected["customer_name"] = true
|
||||
groupCols = []string{"s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu", "s.customer_country", "s.customer_segment"}
|
||||
case "market_customer_product":
|
||||
selected["market_key"] = true
|
||||
selected["customer_code"] = true
|
||||
@@ -2310,6 +2365,11 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
|
||||
selected["color_code"] = true
|
||||
selected["yaka_kodu"] = true
|
||||
selected["item_description"] = true
|
||||
selected["kategori"] = true
|
||||
selected["askili_yan"] = true
|
||||
selected["urun_ilk_grubu"] = true
|
||||
selected["urun_ana_grubu"] = true
|
||||
selected["urun_alt_grubu"] = true
|
||||
groupCols = []string{"s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu"}
|
||||
case "country_segment_market_customer_product":
|
||||
selected["country"] = true
|
||||
@@ -2321,15 +2381,29 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
|
||||
selected["color_code"] = true
|
||||
selected["yaka_kodu"] = true
|
||||
selected["item_description"] = true
|
||||
selected["kategori"] = true
|
||||
selected["askili_yan"] = true
|
||||
selected["urun_ilk_grubu"] = true
|
||||
selected["urun_ana_grubu"] = true
|
||||
selected["urun_alt_grubu"] = true
|
||||
groupCols = []string{"s.customer_country", "s.customer_segment", "s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu"}
|
||||
default:
|
||||
mode = "color_yaka_market_customer"
|
||||
selected["product_code"] = true
|
||||
selected["color_code"] = true
|
||||
selected["yaka_kodu"] = true
|
||||
selected["item_description"] = true
|
||||
selected["kategori"] = true
|
||||
selected["askili_yan"] = true
|
||||
selected["urun_ilk_grubu"] = true
|
||||
selected["urun_ana_grubu"] = true
|
||||
selected["urun_alt_grubu"] = true
|
||||
selected["country"] = true
|
||||
selected["customer_segment"] = true
|
||||
selected["market_key"] = true
|
||||
selected["customer_code"] = true
|
||||
selected["customer_name"] = true
|
||||
groupCols = []string{"s.color_code", "s.yaka_kodu", "s.market_key", "s.customer_code"}
|
||||
groupCols = []string{"s.color_code", "s.yaka_kodu", "s.askili_yan", "s.urun_ilk_grubu", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
|
||||
}
|
||||
|
||||
selectExpr := func(name string) string {
|
||||
@@ -2340,7 +2414,31 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH Agg AS (
|
||||
WITH LatestKPI AS (
|
||||
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
||||
product_code,
|
||||
color_code,
|
||||
yaka_kodu,
|
||||
kategori,
|
||||
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
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
||||
ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
|
||||
),
|
||||
StockAgg AS (
|
||||
SELECT
|
||||
product_code,
|
||||
color_code,
|
||||
yaka_kodu,
|
||||
COALESCE(SUM(stock_qty),0) AS stock_qty
|
||||
FROM mk_product_performance_stock_daily
|
||||
WHERE stock_date = (SELECT MAX(stock_date) FROM mk_product_performance_stock_daily)
|
||||
GROUP BY product_code, color_code, yaka_kodu
|
||||
),
|
||||
Agg AS (
|
||||
SELECT
|
||||
$2::text AS breakdown,
|
||||
%s,
|
||||
@@ -2352,7 +2450,13 @@ WITH Agg AS (
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
COUNT(DISTINCT product_code) AS product_count,
|
||||
COALESCE(MAX(stock_qty),0) AS stock_qty,
|
||||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_90d,
|
||||
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d,
|
||||
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d,
|
||||
@@ -2387,6 +2491,8 @@ WITH Agg AS (
|
||||
END AS cost_price_usd_180d,
|
||||
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_qty_365d,
|
||||
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_usd_365d,
|
||||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_365d,
|
||||
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)::integer AS invoice_count_365d,
|
||||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0
|
||||
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||||
@@ -2419,10 +2525,18 @@ WITH Agg AS (
|
||||
FROM (
|
||||
SELECT
|
||||
s.*,
|
||||
COALESCE(k.kategori,'') AS kategori,
|
||||
COALESCE(k.askili_yan,'') AS askili_yan,
|
||||
COALESCE(k.urun_ilk_grubu,'') AS urun_ilk_grubu,
|
||||
COALESCE(k.urun_ana_grubu,'') AS urun_ana_grubu,
|
||||
COALESCE(k.urun_alt_grubu,'') AS urun_alt_grubu,
|
||||
COALESCE(pd.base_price_usd,0) AS base_price_usd,
|
||||
COALESCE(pd.cost_price_usd,0) AS cost_price_usd
|
||||
COALESCE(pd.cost_price_usd,0) AS cost_price_usd,
|
||||
COALESCE(st.stock_qty,0) AS stock_qty
|
||||
FROM mk_product_performance_sales_daily s
|
||||
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code
|
||||
LEFT JOIN LatestKPI k ON k.product_code = s.product_code AND k.color_code = s.color_code AND k.yaka_kodu = s.yaka_kodu
|
||||
LEFT JOIN StockAgg st ON st.product_code = s.product_code AND st.color_code = s.color_code AND st.yaka_kodu = s.yaka_kodu
|
||||
) s
|
||||
WHERE sales_date >= DATE '2022-01-01'
|
||||
GROUP BY %s
|
||||
@@ -2463,12 +2577,18 @@ SELECT
|
||||
color_code,
|
||||
yaka_kodu,
|
||||
item_description,
|
||||
kategori,
|
||||
askili_yan,
|
||||
urun_ilk_grubu,
|
||||
urun_ana_grubu,
|
||||
urun_alt_grubu,
|
||||
market_key,
|
||||
country,
|
||||
customer_segment,
|
||||
customer_code,
|
||||
customer_name,
|
||||
product_count,
|
||||
stock_qty,
|
||||
customer_count_90d,
|
||||
invoice_count_90d,
|
||||
sales_qty_90d,
|
||||
@@ -2500,6 +2620,8 @@ SELECT
|
||||
gross_profit_cost_usd_365d,
|
||||
gross_margin_base_365d,
|
||||
gross_margin_cost_365d,
|
||||
customer_count_365d,
|
||||
invoice_count_365d,
|
||||
customer_count_total,
|
||||
invoice_count_total,
|
||||
sales_qty_total,
|
||||
@@ -2511,15 +2633,55 @@ SELECT
|
||||
gross_profit_cost_usd_total,
|
||||
gross_margin_base_total,
|
||||
gross_margin_cost_total,
|
||||
CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_90d,
|
||||
CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_180d,
|
||||
CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_365d,
|
||||
CASE WHEN stock_qty > 0 THEN sales_qty_total / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_total,
|
||||
has_cost,
|
||||
sales_index_90d,
|
||||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||||
ROUND((
|
||||
LEAST(35, sales_usd_90d / 1000)
|
||||
+ LEAST(25, GREATEST(gross_margin_cost_90d,0) * 55)
|
||||
+ LEAST(20, invoice_count_90d * 2)
|
||||
+ LEAST(10, sales_qty_90d / 10)
|
||||
+ LEAST(10, product_count)
|
||||
)::numeric, 4)
|
||||
END AS customer_score_90d,
|
||||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||||
ROUND((
|
||||
LEAST(35, sales_usd_180d / 1000)
|
||||
+ LEAST(25, GREATEST(gross_margin_cost_180d,0) * 55)
|
||||
+ LEAST(20, invoice_count_180d * 2)
|
||||
+ LEAST(10, sales_qty_180d / 10)
|
||||
+ LEAST(10, product_count)
|
||||
)::numeric, 4)
|
||||
END AS customer_score_180d,
|
||||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||||
ROUND((
|
||||
LEAST(35, sales_usd_365d / 1000)
|
||||
+ LEAST(25, GREATEST(gross_margin_cost_365d,0) * 55)
|
||||
+ LEAST(20, invoice_count_365d * 2)
|
||||
+ LEAST(10, sales_qty_365d / 10)
|
||||
+ LEAST(10, product_count)
|
||||
)::numeric, 4)
|
||||
END AS customer_score_365d,
|
||||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||||
ROUND((
|
||||
LEAST(35, sales_usd_total / 1000)
|
||||
+ LEAST(25, GREATEST(gross_margin_cost_total,0) * 55)
|
||||
+ LEAST(20, invoice_count_total * 2)
|
||||
+ LEAST(10, sales_qty_total / 10)
|
||||
+ LEAST(10, product_count)
|
||||
)::numeric, 4)
|
||||
END AS customer_score_total,
|
||||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||||
ROUND(
|
||||
LEAST(35, sales_index_90d * 18)
|
||||
+ LEAST(30, GREATEST(gross_margin_cost_90d,0) * 60)
|
||||
+ LEAST(15, invoice_count_90d * 1.5)
|
||||
+ LEAST(10, sales_qty_90d / 10)
|
||||
+ CASE WHEN last_sale_date >= to_char(current_date - INTERVAL '30 days','YYYY-MM-DD') THEN 10 ELSE 0 END,
|
||||
+ LEAST(10, (CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END) * 5),
|
||||
4
|
||||
)
|
||||
END AS performance_score,
|
||||
@@ -2545,6 +2707,11 @@ LIMIT $1
|
||||
selectExpr("color_code"),
|
||||
selectExpr("yaka_kodu"),
|
||||
selectExpr("item_description"),
|
||||
selectExpr("kategori"),
|
||||
selectExpr("askili_yan"),
|
||||
selectExpr("urun_ilk_grubu"),
|
||||
selectExpr("urun_ana_grubu"),
|
||||
selectExpr("urun_alt_grubu"),
|
||||
selectExpr("market_key"),
|
||||
selectExpr("country"),
|
||||
selectExpr("customer_segment"),
|
||||
@@ -2563,18 +2730,20 @@ LIMIT $1
|
||||
var r models.ProductPerformanceSalesBreakdownRow
|
||||
if err := rows.Scan(
|
||||
&r.Breakdown, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
|
||||
&r.Kategori, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu,
|
||||
&r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName,
|
||||
&r.ProductCount, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90,
|
||||
&r.ProductCount, &r.StockQty, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90,
|
||||
&r.AvgPriceUSD90, &r.BasePriceUSD90, &r.CostPriceUSD90, &r.GrossProfitBase90,
|
||||
&r.GrossProfitCost90, &r.GrossMarginBase90, &r.GrossMarginCost90, &r.CustomerCount180,
|
||||
&r.InvoiceCount180, &r.SalesQty180, &r.SalesUSD180, &r.AvgPriceUSD180, &r.BasePriceUSD180,
|
||||
&r.CostPriceUSD180, &r.GrossProfitBase180, &r.GrossProfitCost180, &r.GrossMarginBase180,
|
||||
&r.GrossMarginCost180, &r.SalesQty365, &r.SalesUSD365, &r.AvgPriceUSD365, &r.BasePriceUSD365,
|
||||
&r.CostPriceUSD365, &r.GrossProfitBase365, &r.GrossProfitCost365, &r.GrossMarginBase365,
|
||||
&r.GrossMarginCost365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal,
|
||||
&r.GrossMarginCost365, &r.CustomerCount365, &r.InvoiceCount365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal,
|
||||
&r.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal,
|
||||
&r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal, &r.HasCost,
|
||||
&r.SalesIndex90, &r.PerformanceScore,
|
||||
&r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal,
|
||||
&r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.HasCost,
|
||||
&r.SalesIndex90, &r.CustomerScore90, &r.CustomerScore180, &r.CustomerScore365, &r.CustomerScoreTotal, &r.PerformanceScore,
|
||||
&r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -2883,7 +3052,7 @@ WITH Source AS (
|
||||
|
||||
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":
|
||||
case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu":
|
||||
return field, true
|
||||
case "market_key":
|
||||
return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true
|
||||
@@ -2921,7 +3090,7 @@ func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode s
|
||||
}
|
||||
return productPerformanceIdleSourceRows(structsToMaps(rows)), nil
|
||||
case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product":
|
||||
rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, mode, limit)
|
||||
rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, productPerformanceSalesBreakdownMode(mode), limit)
|
||||
return structsToMaps(rows), err
|
||||
case "order_product_customers":
|
||||
rows, err := ListProductPerformanceOrderProductCustomers(ctx, pg, limit)
|
||||
@@ -2935,6 +3104,21 @@ func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode s
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceSalesBreakdownMode(mode string) string {
|
||||
switch strings.TrimSpace(mode) {
|
||||
case "sales_color_yaka_market_customer":
|
||||
return "color_yaka_market_customer"
|
||||
case "sales_product_country_segment_market_customer":
|
||||
return "product_country_segment_market_customer"
|
||||
case "sales_market_customer_product":
|
||||
return "market_customer_product"
|
||||
case "sales_country_segment_market_customer_product":
|
||||
return "country_segment_market_customer_product"
|
||||
default:
|
||||
return mode
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceIdleSourceRows(rows []map[string]any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
@@ -2987,7 +3171,7 @@ func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map
|
||||
}
|
||||
|
||||
func makeProductPerformanceGroupedRow(key string, level int, field, value string, rows []map[string]any) map[string]any {
|
||||
row := aggregateProductPerformanceRows(rows)
|
||||
row := aggregateProductPerformanceRows(rows, field)
|
||||
row["__group"] = true
|
||||
row["row_key"] = "group|" + key
|
||||
row["key"] = key
|
||||
@@ -3006,7 +3190,7 @@ func makeProductPerformanceGroupedRow(key string, level int, field, value string
|
||||
return row
|
||||
}
|
||||
|
||||
func aggregateProductPerformanceRows(rows []map[string]any) map[string]any {
|
||||
func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, row := range rows {
|
||||
for key, value := range row {
|
||||
@@ -3027,12 +3211,12 @@ func aggregateProductPerformanceRows(rows []map[string]any) map[string]any {
|
||||
}
|
||||
}
|
||||
}
|
||||
deriveProductPerformanceGroupMetrics(out)
|
||||
deriveProductPerformanceGroupMetrics(out, groupField)
|
||||
out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket")
|
||||
return out
|
||||
}
|
||||
|
||||
func deriveProductPerformanceGroupMetrics(out map[string]any) {
|
||||
func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) {
|
||||
for _, suffix := range []string{"90d", "180d", "365d", "total"} {
|
||||
sales := floatFromMap(out, "sales_usd_"+suffix)
|
||||
qty := floatFromMap(out, "sales_qty_"+suffix)
|
||||
@@ -3064,13 +3248,138 @@ func deriveProductPerformanceGroupMetrics(out map[string]any) {
|
||||
if orderQty > 0 {
|
||||
out["avg_order_price_usd"] = orderUSD / orderQty
|
||||
}
|
||||
out["customer_score_90d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "90d"))
|
||||
out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d"))
|
||||
out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d"))
|
||||
out["customer_score_total"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "total"))
|
||||
out["performance_score"] = productPerformanceGroupScore(out, groupField)
|
||||
}
|
||||
|
||||
func productPerformanceGroupScore(row map[string]any, groupField string) float64 {
|
||||
if floatFromMap(row, "order_qty") > 0 || floatFromMap(row, "order_usd") > 0 {
|
||||
return productPerformanceOrderGroupScore(row)
|
||||
}
|
||||
if isProductPerformanceCustomerGroup(groupField) {
|
||||
return productPerformanceCustomerSalesGroupScore(row)
|
||||
}
|
||||
return productPerformanceSalesGroupScore(row)
|
||||
}
|
||||
|
||||
func isProductPerformanceCustomerGroup(groupField string) bool {
|
||||
switch strings.TrimSpace(groupField) {
|
||||
case "customer_code", "customer_name":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceSalesGroupScore(row map[string]any) float64 {
|
||||
salesIndex := floatFromMap(row, "sales_index_90d")
|
||||
margin := floatFromMap(row, "gross_margin_cost_90d")
|
||||
if margin == 0 {
|
||||
margin = floatFromMap(row, "gross_margin_90d")
|
||||
}
|
||||
invoiceCount := floatFromMap(row, "invoice_count_90d")
|
||||
salesQty := floatFromMap(row, "sales_qty_90d")
|
||||
stockTurnover := floatFromMap(row, "stock_turnover_90d")
|
||||
if stockTurnover == 0 {
|
||||
if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 {
|
||||
stockTurnover = salesQty / stockQty
|
||||
}
|
||||
}
|
||||
score := minFloat(35, maxFloat(0, salesIndex)*18) +
|
||||
minFloat(30, maxFloat(0, margin)*60) +
|
||||
minFloat(15, invoiceCount*1.5) +
|
||||
minFloat(10, salesQty/10) +
|
||||
minFloat(10, maxFloat(0, stockTurnover)*5)
|
||||
return score
|
||||
}
|
||||
|
||||
func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 {
|
||||
scores := []float64{
|
||||
floatFromMap(row, "customer_score_90d"),
|
||||
floatFromMap(row, "customer_score_180d"),
|
||||
floatFromMap(row, "customer_score_365d"),
|
||||
floatFromMap(row, "customer_score_total"),
|
||||
}
|
||||
total := 0.0
|
||||
count := 0.0
|
||||
for _, score := range scores {
|
||||
if score <= 0 {
|
||||
continue
|
||||
}
|
||||
total += score
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return 0
|
||||
}
|
||||
return total / count
|
||||
}
|
||||
|
||||
func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 {
|
||||
salesUSD := floatFromMap(row, "sales_usd")
|
||||
margin := floatFromMap(row, "gross_margin_cost")
|
||||
if margin == 0 {
|
||||
margin = floatFromMap(row, "gross_margin")
|
||||
}
|
||||
invoiceCount := floatFromMap(row, "invoice_count")
|
||||
salesQty := floatFromMap(row, "sales_qty")
|
||||
productCount := floatFromMap(row, "product_count")
|
||||
return minFloat(35, salesUSD/1000) +
|
||||
minFloat(25, maxFloat(0, margin)*55) +
|
||||
minFloat(20, invoiceCount*2) +
|
||||
minFloat(10, salesQty/10) +
|
||||
minFloat(10, productCount)
|
||||
}
|
||||
|
||||
func rowPeriodMetricMap(row map[string]any, suffix string) map[string]any {
|
||||
return map[string]any{
|
||||
"sales_usd": floatFromMap(row, "sales_usd_"+suffix),
|
||||
"gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix),
|
||||
"gross_margin": floatFromMap(row, "gross_margin_"+suffix),
|
||||
"invoice_count": floatFromMap(row, "invoice_count_"+suffix),
|
||||
"sales_qty": floatFromMap(row, "sales_qty_"+suffix),
|
||||
"product_count": floatFromMap(row, "product_count"),
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceOrderGroupScore(row map[string]any) float64 {
|
||||
orderUSD := floatFromMap(row, "order_usd")
|
||||
margin := floatFromMap(row, "expected_margin_cost")
|
||||
orderCount := floatFromMap(row, "order_count")
|
||||
orderQty := floatFromMap(row, "order_qty")
|
||||
netStockAfterOrder := floatFromMap(row, "net_stock_after_order")
|
||||
score := minFloat(35, orderUSD/1000) +
|
||||
minFloat(25, maxFloat(0, margin)*60) +
|
||||
minFloat(15, orderCount*2) +
|
||||
minFloat(15, orderQty/10)
|
||||
if netStockAfterOrder >= 0 {
|
||||
score += 10
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func minFloat(a, b float64) float64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxFloat(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func sanitizeProductPerformanceGroupLevels(levels []string) []string {
|
||||
allowed := map[string]bool{
|
||||
"kategori": true, "askili_yan": true, "urun_ilk_grubu": true, "urun_ana_grubu": true, "urun_alt_grubu": true,
|
||||
"product_code": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true,
|
||||
"country": true, "customer_segment": true,
|
||||
"product_code": true, "item_description": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true,
|
||||
"customer_name": true, "country": true, "customer_segment": true,
|
||||
}
|
||||
out := make([]string, 0, len(levels))
|
||||
seen := map[string]bool{}
|
||||
@@ -3087,21 +3396,21 @@ func sanitizeProductPerformanceGroupLevels(levels []string) []string {
|
||||
func defaultProductPerformanceGroupLevels(mode string) []string {
|
||||
switch mode {
|
||||
case "sales_color_yaka_market_customer":
|
||||
return []string{"color_code", "yaka_kodu", "market_key", "customer_code"}
|
||||
return []string{"color_code", "yaka_kodu", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "country", "market_key", "customer_segment", "customer_code", "customer_name"}
|
||||
case "idle":
|
||||
return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code"}
|
||||
return []string{"askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
case "sales_product_country_segment_market_customer":
|
||||
return []string{"product_code", "country", "customer_segment", "market_key", "customer_code"}
|
||||
return []string{"market_key", "customer_code", "customer_name", "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
case "sales_market_customer_product":
|
||||
return []string{"market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
|
||||
return []string{"market_key", "customer_code", "customer_name", "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
case "sales_country_segment_market_customer_product":
|
||||
return []string{"country", "customer_segment", "market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
|
||||
return []string{"market_key", "customer_code", "customer_name", "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
case "order_product_customers":
|
||||
return []string{"product_code", "market_key", "customer_code"}
|
||||
return []string{"market_key", "customer_code", "customer_name", "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
case "order_market_details":
|
||||
return []string{"market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
|
||||
return []string{"market_key", "customer_code", "customer_name", "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
default:
|
||||
return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "market_key", "product_code"}
|
||||
return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu", "market_key"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3525,6 +3834,15 @@ type productPerformancePriceInfo struct {
|
||||
base float64
|
||||
}
|
||||
|
||||
type productPerformanceAttrInfo struct {
|
||||
itemDescription string
|
||||
kategori string
|
||||
askiliYan string
|
||||
urunIlkGrubu string
|
||||
urunAnaGrubu string
|
||||
urunAltGrubu string
|
||||
}
|
||||
|
||||
func productPerformanceVariantKey(productCode, colorCode, yakaKodu string) string {
|
||||
return strings.TrimSpace(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(yakaKodu)
|
||||
}
|
||||
@@ -3554,6 +3872,46 @@ WHERE product_code = ANY($1)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func productPerformanceAttrLookup(ctx context.Context, pg *sql.DB, variantKeys []string) (map[string]productPerformanceAttrInfo, error) {
|
||||
out := make(map[string]productPerformanceAttrInfo, len(variantKeys))
|
||||
if len(variantKeys) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
WITH Latest AS (
|
||||
SELECT MAX(kpi_date) AS kpi_date
|
||||
FROM mk_product_performance_kpi_daily
|
||||
)
|
||||
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
||||
product_code,
|
||||
color_code,
|
||||
yaka_kodu,
|
||||
COALESCE(item_description,'') AS item_description,
|
||||
COALESCE(kategori,'') AS kategori,
|
||||
COALESCE(askili_yan,'') AS askili_yan,
|
||||
COALESCE(NULLIF(urun_ilk_grubu,''), yas_grubu, '') AS urun_ilk_grubu,
|
||||
COALESCE(NULLIF(urun_ana_grubu,''), seri, '') AS urun_ana_grubu,
|
||||
COALESCE(urun_alt_grubu,'') AS urun_alt_grubu
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT kpi_date FROM Latest)
|
||||
AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1)
|
||||
ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
|
||||
`, pq.Array(variantKeys))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var productCode, colorCode, yakaKodu string
|
||||
var p productPerformanceAttrInfo
|
||||
if err := rows.Scan(&productCode, &colorCode, &yakaKodu, &p.itemDescription, &p.kategori, &p.askiliYan, &p.urunIlkGrubu, &p.urunAnaGrubu, &p.urunAltGrubu); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[productPerformanceVariantKey(productCode, colorCode, yakaKodu)] = p
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func productPerformanceStockLookup(ctx context.Context, pg *sql.DB, variantKeys []string) (map[string]float64, error) {
|
||||
out := make(map[string]float64, len(variantKeys))
|
||||
if len(variantKeys) == 0 {
|
||||
|
||||
@@ -268,19 +268,50 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
||||
groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels"))
|
||||
limit := intQuery(r, "limit", 50000)
|
||||
expanded := map[string]bool{}
|
||||
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
expanded[key] = true
|
||||
if r.Method == http.MethodPost {
|
||||
var body struct {
|
||||
Mode string `json:"mode"`
|
||||
GroupLevels []string `json:"group_levels"`
|
||||
ExpandedKeys []string `json:"expanded_keys"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Mode) != "" {
|
||||
mode = strings.TrimSpace(body.Mode)
|
||||
}
|
||||
if len(body.GroupLevels) > 0 {
|
||||
groupLevels = body.GroupLevels
|
||||
}
|
||||
if body.Limit > 0 {
|
||||
limit = body.Limit
|
||||
}
|
||||
for _, key := range body.ExpandedKeys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
expanded[key] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
expanded[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := queries.ListProductPerformanceGrouped(ctx, pg, queries.ProductPerformanceGroupedRequest{
|
||||
Mode: strings.TrimSpace(r.URL.Query().Get("mode")),
|
||||
GroupLevels: splitCSVQuery(r.URL.Query().Get("group_levels")),
|
||||
Mode: mode,
|
||||
GroupLevels: groupLevels,
|
||||
ExpandedKeys: expanded,
|
||||
Limit: intQuery(r, "limit", 50000),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
flat
|
||||
bordered
|
||||
row-key="row_key"
|
||||
class="performance-table sticky-dim-table sticky-dim-10 bg-white"
|
||||
class="performance-table sticky-dim-table sticky-dim-8 bg-white"
|
||||
:rows="displayOrderProductCustomerTableRows"
|
||||
:columns="orderProductCustomerColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
@@ -676,7 +676,7 @@
|
||||
flat
|
||||
bordered
|
||||
row-key="row_key"
|
||||
class="performance-table sticky-dim-table sticky-dim-6 bg-white"
|
||||
class="performance-table sticky-dim-table sticky-dim-11 bg-white"
|
||||
:rows="displayIdleTableRows"
|
||||
:columns="idleColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
@@ -1515,14 +1515,14 @@ const columnFilters = reactive({})
|
||||
const columnFilterSearch = reactive({})
|
||||
const expandedGroups = ref({})
|
||||
const selectedExpandLevelKeysByTab = reactive({
|
||||
products: ['kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'market_key'],
|
||||
sales_color_yaka_market_customer: ['color_code', 'yaka_kodu', 'market_key', 'customer_code'],
|
||||
idle: ['kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code'],
|
||||
sales_product_country_segment_market_customer: ['product_code', 'country', 'customer_segment', 'market_key', 'customer_code'],
|
||||
sales_market_customer_product: ['market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu'],
|
||||
sales_country_segment_market_customer_product: ['country', 'customer_segment', 'market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu'],
|
||||
order_product_customers: ['product_code', 'market_key', 'customer_code'],
|
||||
order_market_details: ['market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu']
|
||||
products: ['kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu', 'market_key'],
|
||||
sales_color_yaka_market_customer: ['color_code', 'yaka_kodu', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'country', 'market_key', 'customer_segment', 'customer_code', 'customer_name'],
|
||||
idle: ['askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'],
|
||||
sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'],
|
||||
sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'],
|
||||
sales_country_segment_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'],
|
||||
order_product_customers: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'],
|
||||
order_market_details: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu']
|
||||
})
|
||||
const productKpiFetchLimit = 50000
|
||||
const performanceTabs = [
|
||||
@@ -1530,7 +1530,6 @@ const performanceTabs = [
|
||||
{ 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ş' }
|
||||
@@ -1571,7 +1570,6 @@ const customerBreakdownOptions = [
|
||||
const salesBreakdownModes = {
|
||||
sales_color_yaka_market_customer: 'color_yaka_market_customer',
|
||||
sales_product_country_segment_market_customer: 'product_country_segment_market_customer',
|
||||
sales_market_customer_product: 'market_customer_product',
|
||||
sales_country_segment_market_customer_product: 'country_segment_market_customer_product'
|
||||
}
|
||||
|
||||
@@ -1596,15 +1594,15 @@ const detailSalesColumns = [
|
||||
|
||||
const columns = [
|
||||
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' },
|
||||
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' },
|
||||
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
|
||||
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left' },
|
||||
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' },
|
||||
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' },
|
||||
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
|
||||
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
@@ -1773,14 +1771,19 @@ const orderGroupColumns = [
|
||||
]
|
||||
|
||||
const orderProductCustomerColumns = [
|
||||
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
|
||||
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
|
||||
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
|
||||
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
|
||||
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
|
||||
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
|
||||
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
|
||||
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left', sortable: true },
|
||||
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left', sortable: true },
|
||||
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
|
||||
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
|
||||
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
@@ -1803,13 +1806,18 @@ const orderMarketDetailColumns = [
|
||||
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
|
||||
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
|
||||
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
|
||||
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
|
||||
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
|
||||
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left', sortable: true },
|
||||
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left', sortable: true },
|
||||
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
|
||||
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
|
||||
{ name: 'order_number', label: 'Sipariş No', field: 'order_number', align: 'left', sortable: true },
|
||||
{ name: 'order_date', label: 'Sipariş Tarihi', field: 'order_date', align: 'left', sortable: true },
|
||||
{ name: 'due_date', label: 'Termin', field: 'due_date', align: 'left', sortable: true },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
|
||||
{ name: 'order_qty', label: 'Adet', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'order_usd', label: 'USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
@@ -1826,15 +1834,16 @@ const orderMarketDetailColumns = [
|
||||
|
||||
const idleColumns = [
|
||||
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' },
|
||||
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' },
|
||||
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
|
||||
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left' },
|
||||
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' },
|
||||
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' },
|
||||
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
|
||||
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left' },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd', label: 'Maliyet USD', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true },
|
||||
@@ -1888,6 +1897,7 @@ const customerColumns = [
|
||||
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 1), align: 'right', sortable: true },
|
||||
{ name: 'base_price_usd_90d', label: '90G Taban', field: row => formatMoney(row.base_price_usd_90d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd_90d', label: '90G Çıplak', field: row => formatMoney(row.cost_price_usd_90d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_base_usd_90d', label: '90G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_90d, 'USD'), align: 'right', sortable: true },
|
||||
@@ -1901,52 +1911,65 @@ const customerColumns = [
|
||||
]
|
||||
|
||||
const salesBreakdownColumns = [
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
|
||||
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
|
||||
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left', sortable: true },
|
||||
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left', sortable: true },
|
||||
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
|
||||
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
|
||||
{ name: 'customer_segment', label: 'Segment', field: 'customer_segment', align: 'left', sortable: true },
|
||||
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
|
||||
{ name: 'customer_segment', label: 'Segment', field: 'customer_segment', align: 'left', sortable: true },
|
||||
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
|
||||
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
|
||||
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
|
||||
{ name: 'product_count', label: 'Ürün Sayısı', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 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: 'invoice_count_90d', label: '90G Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_90d', label: '90G Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'customer_count_180d', label: '180G Müşteri', field: row => formatNumber(row.customer_count_180d, 0), align: 'right', sortable: true },
|
||||
{ name: 'invoice_count_180d', label: '180G Fatura', field: row => formatNumber(row.invoice_count_180d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_180d', label: '180G Adet', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_180d', label: '180G USD', field: row => formatMoney(row.sales_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_180d', label: '180G Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
|
||||
{ name: 'base_price_usd_180d', label: '180G Taban', field: row => formatMoney(row.base_price_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd_180d', label: '180G Çıplak', field: row => formatMoney(row.cost_price_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_base_usd_180d', label: '180G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_cost_usd_180d', label: '180G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_180d, 'USD'), 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: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 1), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_365d', label: '360G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_365d', label: '360G Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
|
||||
{ name: 'base_price_usd_365d', label: '360G Taban', field: row => formatMoney(row.base_price_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd_365d', label: '360G Çıplak', field: row => formatMoney(row.cost_price_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_base_usd_365d', label: '360G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_cost_usd_365d', label: '360G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_margin_base_365d', label: '360G Taban Marj', field: row => formatPercent(row.gross_margin_base_365d), align: 'right', sortable: true },
|
||||
{ name: 'gross_margin_cost_365d', label: '360G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_365d), align: 'right', sortable: true },
|
||||
{ name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 1), align: 'right', sortable: true },
|
||||
{ name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
|
||||
{ name: 'invoice_count_total', label: 'Genel Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_total', label: 'Genel Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_total', label: 'Genel USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_total', label: 'Genel Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
|
||||
{ name: 'base_price_usd_total', label: 'Genel Taban', field: row => formatMoney(row.base_price_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd_total', label: 'Genel Çıplak', field: row => formatMoney(row.cost_price_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_cost_usd_total', label: 'Genel Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_total, 'USD'), 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: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 1), align: 'right', sortable: true },
|
||||
{ name: 'sales_index_90d', label: 'Endeks', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true },
|
||||
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
|
||||
@@ -2076,8 +2099,11 @@ const groupLevels = [
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'product_code', label: 'Ürün Kodu' }
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'item_description', label: 'Açıklama' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' },
|
||||
{ key: 'market_key', label: 'Piyasa' }
|
||||
]
|
||||
|
||||
const tabGroupLevels = {
|
||||
@@ -2085,49 +2111,95 @@ const tabGroupLevels = {
|
||||
sales_color_yaka_market_customer: [
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' },
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'item_description', label: 'Açıklama' },
|
||||
{ key: 'country', label: 'Ülke' },
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri' }
|
||||
{ key: 'customer_segment', label: 'Segment' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' }
|
||||
],
|
||||
idle: [
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'item_description', label: 'Açıklama' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
],
|
||||
sales_product_country_segment_market_customer: [
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' },
|
||||
{ key: 'kategori', label: 'Kategori' },
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün Kodu' }
|
||||
],
|
||||
sales_product_country_segment_market_customer: [
|
||||
{ key: 'product_code', label: 'Ürün Kodu' },
|
||||
{ key: 'country', label: 'Ülke' },
|
||||
{ key: 'customer_segment', label: 'Segment' },
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri' }
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'item_description', label: 'Açıklama' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
],
|
||||
sales_market_customer_product: [
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri' },
|
||||
{ key: 'product_code', label: 'Ürün Kodu' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' },
|
||||
{ key: 'kategori', label: 'Kategori' },
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'item_description', label: 'Açıklama' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
],
|
||||
sales_country_segment_market_customer_product: [
|
||||
{ key: 'country', label: 'Ülke' },
|
||||
{ key: 'customer_segment', label: 'Segment' },
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri' },
|
||||
{ key: 'product_code', label: 'Ürün Kodu' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' },
|
||||
{ key: 'kategori', label: 'Kategori' },
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'item_description', label: 'Açıklama' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
],
|
||||
order_product_customers: [
|
||||
{ key: 'product_code', label: 'Ürün Kodu' },
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri' }
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' },
|
||||
{ key: 'kategori', label: 'Kategori' },
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'item_description', label: 'Açıklama' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
],
|
||||
order_market_details: [
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri' },
|
||||
{ key: 'product_code', label: 'Ürün Kodu' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' },
|
||||
{ key: 'kategori', label: 'Kategori' },
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'item_description', label: 'Açıklama' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
]
|
||||
@@ -2321,6 +2393,7 @@ function sortProductLeafRows (sourceRows) {
|
||||
|
||||
function makeGroupRow (key, level, groupDef, value, groupRows) {
|
||||
const imageSource = groupRows.find(row => row?.product_code) || {}
|
||||
const aggregate = aggregateGroupFields(groupRows, groupDef.key)
|
||||
return {
|
||||
__group: true,
|
||||
row_key: `group|${key}`,
|
||||
@@ -2343,6 +2416,10 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
|
||||
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: groupDef.key === 'market_key' ? value : distinctSummary(groupRows, 'market_key'),
|
||||
country: groupDef.key === 'country' ? value : distinctSummary(groupRows, 'country'),
|
||||
customer_segment: groupDef.key === 'customer_segment' ? value : distinctSummary(groupRows, 'customer_segment'),
|
||||
customer_code: groupDef.key === 'customer_code' ? value : distinctSummary(groupRows, 'customer_code'),
|
||||
customer_name: groupDef.key === 'customer_name' ? value : distinctSummary(groupRows, 'customer_name'),
|
||||
stock_qty: sumRows(groupRows, 'stock_qty'),
|
||||
sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'),
|
||||
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
|
||||
@@ -2384,14 +2461,14 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
|
||||
stock_days_total: weightedAverageOrAverage(groupRows, 'stock_days_total', 'stock_qty'),
|
||||
sales_index_90d: averageRows(groupRows, 'sales_index_90d'),
|
||||
sales_index_total: averageRows(groupRows, 'sales_index_total'),
|
||||
performance_score: weightedAverageOrAverage(groupRows, 'performance_score', 'sales_qty_90d'),
|
||||
performance_bucket: dominantValue(groupRows, 'performance_bucket'),
|
||||
...aggregateGroupFields(groupRows),
|
||||
...aggregate,
|
||||
performance_score: groupPerformanceScore(aggregate, groupDef.key),
|
||||
recommendation: `${formatNumber(groupRows.length, 0)} satır`
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateGroupFields (sourceRows) {
|
||||
function aggregateGroupFields (sourceRows, groupField = '') {
|
||||
const out = {}
|
||||
const fields = new Set()
|
||||
for (const row of sourceRows) {
|
||||
@@ -2407,7 +2484,7 @@ function aggregateGroupFields (sourceRows) {
|
||||
}
|
||||
}
|
||||
|
||||
applyDerivedGroupMetrics(out, sourceRows)
|
||||
applyDerivedGroupMetrics(out, sourceRows, groupField)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -2429,7 +2506,7 @@ function weightFieldForMetric (field) {
|
||||
return 'sales_qty_90d'
|
||||
}
|
||||
|
||||
function applyDerivedGroupMetrics (out, sourceRows) {
|
||||
function applyDerivedGroupMetrics (out, sourceRows, groupField = '') {
|
||||
for (const suffix of ['90d', '180d', '365d', 'total']) {
|
||||
const sales = Number(out[`sales_usd_${suffix}`] || 0)
|
||||
const qty = Number(out[`sales_qty_${suffix}`] || 0)
|
||||
@@ -2459,6 +2536,82 @@ function applyDerivedGroupMetrics (out, sourceRows) {
|
||||
if (sourceRows.some(row => row.performance_bucket)) {
|
||||
out.performance_bucket = dominantValue(sourceRows, 'performance_bucket')
|
||||
}
|
||||
out.customer_score_90d = customerSalesPeriodScore(periodMetricSource(out, '90d'))
|
||||
out.customer_score_180d = customerSalesPeriodScore(periodMetricSource(out, '180d'))
|
||||
out.customer_score_365d = customerSalesPeriodScore(periodMetricSource(out, '365d'))
|
||||
out.customer_score_total = customerSalesPeriodScore(periodMetricSource(out, 'total'))
|
||||
out.performance_score = groupPerformanceScore(out, groupField)
|
||||
}
|
||||
|
||||
function groupPerformanceScore (row, groupField = '') {
|
||||
if (Number(row?.order_qty || 0) > 0 || Number(row?.order_usd || 0) > 0) return orderGroupPerformanceScore(row)
|
||||
if (['customer_code', 'customer_name'].includes(groupField)) return customerSalesGroupPerformanceScore(row)
|
||||
return salesGroupPerformanceScore(row)
|
||||
}
|
||||
|
||||
function salesGroupPerformanceScore (row) {
|
||||
const salesIndex = Number(row?.sales_index_90d || 0)
|
||||
const margin = Number(row?.gross_margin_cost_90d ?? row?.gross_margin_90d ?? 0)
|
||||
const invoices = Number(row?.invoice_count_90d || 0)
|
||||
const qty = Number(row?.sales_qty_90d || 0)
|
||||
let stockTurnover = Number(row?.stock_turnover_90d || 0)
|
||||
const stockQty = Number(row?.stock_qty || 0)
|
||||
if (!stockTurnover && stockQty > 0) stockTurnover = qty / stockQty
|
||||
return Math.min(35, Math.max(0, salesIndex) * 18) +
|
||||
Math.min(30, Math.max(0, margin) * 60) +
|
||||
Math.min(15, invoices * 1.5) +
|
||||
Math.min(10, qty / 10) +
|
||||
Math.min(10, Math.max(0, stockTurnover) * 5)
|
||||
}
|
||||
|
||||
function customerSalesGroupPerformanceScore (row) {
|
||||
const periodToScore = {
|
||||
90: Number(row?.customer_score_90d || 0),
|
||||
180: Number(row?.customer_score_180d || 0),
|
||||
360: Number(row?.customer_score_365d || 0),
|
||||
total: Number(row?.customer_score_total || 0)
|
||||
}
|
||||
const selected = selectedPeriods.value.length ? selectedPeriods.value : ['90', '180', '360', 'total']
|
||||
const scores = selected.map(period => periodToScore[period]).filter(score => Number.isFinite(score) && score > 0)
|
||||
if (!scores.length) return 0
|
||||
return scores.reduce((sum, score) => sum + score, 0) / scores.length
|
||||
}
|
||||
|
||||
function customerSalesPeriodScore (row) {
|
||||
const salesUSD = Number(row?.sales_usd || 0)
|
||||
const margin = Number(row?.gross_margin_cost ?? row?.gross_margin ?? 0)
|
||||
const invoices = Number(row?.invoice_count || 0)
|
||||
const qty = Number(row?.sales_qty || 0)
|
||||
const productCount = Number(row?.product_count || 0)
|
||||
return Math.min(35, salesUSD / 1000) +
|
||||
Math.min(25, Math.max(0, margin) * 55) +
|
||||
Math.min(20, invoices * 2) +
|
||||
Math.min(10, qty / 10) +
|
||||
Math.min(10, productCount)
|
||||
}
|
||||
|
||||
function periodMetricSource (row, suffix) {
|
||||
return {
|
||||
sales_usd: Number(row?.[`sales_usd_${suffix}`] || 0),
|
||||
gross_margin_cost: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
|
||||
gross_margin: Number(row?.[`gross_margin_${suffix}`] ?? 0),
|
||||
invoice_count: Number(row?.[`invoice_count_${suffix}`] || 0),
|
||||
sales_qty: Number(row?.[`sales_qty_${suffix}`] || 0),
|
||||
product_count: Number(row?.product_count || 0)
|
||||
}
|
||||
}
|
||||
|
||||
function orderGroupPerformanceScore (row) {
|
||||
const orderUSD = Number(row?.order_usd || 0)
|
||||
const margin = Number(row?.expected_margin_cost || 0)
|
||||
const orders = Number(row?.order_count || 0)
|
||||
const qty = Number(row?.order_qty || 0)
|
||||
const stockBonus = Number(row?.net_stock_after_order || 0) >= 0 ? 10 : 0
|
||||
return Math.min(35, orderUSD / 1000) +
|
||||
Math.min(25, Math.max(0, margin) * 60) +
|
||||
Math.min(15, orders * 2) +
|
||||
Math.min(15, qty / 10) +
|
||||
stockBonus
|
||||
}
|
||||
|
||||
function sumRows (sourceRows, field) {
|
||||
@@ -2601,7 +2754,7 @@ function groupCellClass (colOrName) {
|
||||
const name = colOrName?.name || colOrName
|
||||
if (name === 'image') return 'product-image-cell'
|
||||
if (colOrName?.align === 'right') return 'text-right'
|
||||
return ['stock_qty', 'sales_qty_90d', 'sales_qty_180d', 'sales_qty_365d', 'sales_qty_total', 'sales_usd_90d', 'sales_usd_180d', 'sales_usd_365d', 'sales_usd_total', 'stock_days_90d', 'stock_days_180d', 'stock_days_total', 'stock_turnover_90d', 'stock_turnover_180d', 'stock_turnover_365d', 'stock_turnover_total', 'avg_price_usd_90d', 'avg_price_usd_180d', 'avg_price_usd_365d', 'avg_price_usd_total', 'base_price_usd', 'cost_price_usd', 'unit_profit_base_90d', 'unit_profit_cost_90d', 'unit_profit_base_180d', 'unit_profit_cost_180d', 'unit_profit_base_total', 'unit_profit_cost_total', 'gross_profit_usd_90d', 'gross_profit_usd_180d', 'gross_profit_usd_total', 'gross_margin_base_90d', 'gross_margin_cost_90d', 'gross_margin_base_180d', 'gross_margin_cost_180d', 'gross_margin_base_total', 'gross_margin_cost_total', 'gross_margin_90d', 'gross_margin_180d', 'market_count_90d', 'customer_count_90d', 'market_count_total', 'customer_count_total', 'sales_index_90d', 'sales_index_total', 'performance_score'].includes(name)
|
||||
return ['stock_qty', 'sales_qty_90d', 'sales_qty_180d', 'sales_qty_365d', 'sales_qty_total', 'sales_usd_90d', 'sales_usd_180d', 'sales_usd_365d', 'sales_usd_total', 'stock_days_90d', 'stock_days_180d', 'stock_days_total', 'stock_turnover_90d', 'stock_turnover_180d', 'stock_turnover_365d', 'stock_turnover_total', 'avg_price_usd_90d', 'avg_price_usd_180d', 'avg_price_usd_365d', 'avg_price_usd_total', 'base_price_usd', 'cost_price_usd', 'unit_profit_base_90d', 'unit_profit_cost_90d', 'unit_profit_base_180d', 'unit_profit_cost_180d', 'unit_profit_base_total', 'unit_profit_cost_total', 'gross_profit_usd_90d', 'gross_profit_usd_180d', 'gross_profit_usd_total', 'gross_margin_base_90d', 'gross_margin_cost_90d', 'gross_margin_base_180d', 'gross_margin_cost_180d', 'gross_margin_base_total', 'gross_margin_cost_total', 'gross_margin_90d', 'gross_margin_180d', 'market_count_90d', 'customer_count_90d', 'market_count_total', 'customer_count_total', 'sales_index_90d', 'sales_index_total', 'customer_score_90d', 'customer_score_180d', 'customer_score_365d', 'customer_score_total', 'performance_score'].includes(name)
|
||||
? 'text-right'
|
||||
: ''
|
||||
}
|
||||
@@ -2954,8 +3107,13 @@ function normalizeOrderMarketDetailRow (row) {
|
||||
}
|
||||
|
||||
function normalizeSalesBreakdownRow (row) {
|
||||
const next = { ...row }
|
||||
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')))
|
||||
next.customer_score_total = Number(next.customer_score_total || customerSalesPeriodScore(periodMetricSource(next, 'total')))
|
||||
return {
|
||||
...row,
|
||||
...next,
|
||||
row_key: `sales-breakdown|${row?.breakdown || ''}|${row?.product_code || ''}|${row?.color_code || ''}|${row?.yaka_kodu || ''}|${row?.country || ''}|${row?.customer_segment || ''}|${row?.market_key || ''}|${row?.customer_code || ''}`
|
||||
}
|
||||
}
|
||||
@@ -3165,9 +3323,13 @@ function backendGroupedSupportedTab (tabKey = activeTab.value) {
|
||||
}
|
||||
|
||||
function activeExpandedGroupKeys () {
|
||||
const tabPrefix = `tab:${activeTab.value}|`
|
||||
const validFields = new Set(activeGroupLevels.value.map(level => level.key))
|
||||
return Object.entries(expandedGroups.value)
|
||||
.filter(([, value]) => value === true)
|
||||
.map(([key]) => key)
|
||||
.filter(key => key.startsWith(tabPrefix))
|
||||
.filter(key => key.split('|').slice(1).every(part => validFields.has(String(part).split(':')[0])))
|
||||
}
|
||||
|
||||
function scheduleLoadBackendGroupedRows () {
|
||||
@@ -3195,11 +3357,17 @@ async function loadBackendGroupedRows () {
|
||||
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
|
||||
}
|
||||
try {
|
||||
const startedAt = performance.now()
|
||||
const rows = await performanceStore.fetchGroupedRows(params)
|
||||
backendGroupedRows.value = {
|
||||
...backendGroupedRows.value,
|
||||
[tabKey]: Array.isArray(rows) ? rows : []
|
||||
}
|
||||
console.info('[ProductPerformance][ui] grouped applied', {
|
||||
tab: tabKey,
|
||||
rows: Array.isArray(rows) ? rows.length : 0,
|
||||
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2))
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('product performance grouped rows failed', err)
|
||||
} finally {
|
||||
@@ -3207,18 +3375,50 @@ async function loadBackendGroupedRows () {
|
||||
}
|
||||
}
|
||||
|
||||
function responseRowCount (data) {
|
||||
if (Array.isArray(data)) return data.length
|
||||
if (Array.isArray(data?.rows)) return data.rows.length
|
||||
if (data && typeof data === 'object') return Object.keys(data).length
|
||||
return 0
|
||||
}
|
||||
|
||||
async function timedProductPerformanceGet (label, url, config = {}) {
|
||||
const startedAt = performance.now()
|
||||
console.info('[ProductPerformance][api] start', {
|
||||
label,
|
||||
url,
|
||||
params: config?.params || {}
|
||||
})
|
||||
try {
|
||||
const resp = await api.get(url, config)
|
||||
console.info('[ProductPerformance][api] done', {
|
||||
label,
|
||||
rows: responseRowCount(resp?.data),
|
||||
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2))
|
||||
})
|
||||
return resp
|
||||
} catch (err) {
|
||||
console.warn('[ProductPerformance][api] failed', {
|
||||
label,
|
||||
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)),
|
||||
message: err?.response?.data || err?.message || err
|
||||
})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function reload () {
|
||||
loading.value = true
|
||||
try {
|
||||
const salesBreakdownRequests = salesBreakdownTabKeys.map(tabKey => {
|
||||
return api.get('/pricing/product-performance/sales-breakdown', {
|
||||
return timedProductPerformanceGet(`sales-breakdown:${tabKey}`, '/pricing/product-performance/sales-breakdown', {
|
||||
params: { limit: 800, mode: salesBreakdownModes[tabKey] },
|
||||
timeout: 60000
|
||||
})
|
||||
})
|
||||
const [summaryResp, listResp, generalResp, marketResp, countryResp, customerResp, ...salesBreakdownResp] = await Promise.all([
|
||||
api.get('/pricing/product-performance/summary', { timeout: 60000 }),
|
||||
api.get('/pricing/product-performance', {
|
||||
timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 60000 }),
|
||||
timedProductPerformanceGet('product-kpi', '/pricing/product-performance', {
|
||||
params: {
|
||||
page: 1,
|
||||
limit: productKpiFetchLimit,
|
||||
@@ -3227,10 +3427,10 @@ async function reload () {
|
||||
},
|
||||
timeout: 60000
|
||||
}),
|
||||
api.get('/pricing/product-performance/general', { params: { limit: 500 }, timeout: 60000 }),
|
||||
api.get('/pricing/product-performance/markets', { params: { limit: 500 }, timeout: 60000 }),
|
||||
api.get('/pricing/product-performance/countries', { params: { limit: 500 }, timeout: 60000 }),
|
||||
api.get('/pricing/product-performance/customers', {
|
||||
timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: 500 }, timeout: 60000 }),
|
||||
timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: 500 }, timeout: 60000 }),
|
||||
timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: 500 }, timeout: 60000 }),
|
||||
timedProductPerformanceGet('customers', '/pricing/product-performance/customers', {
|
||||
params: { limit: 500, breakdown: customerBreakdown.value },
|
||||
timeout: 60000
|
||||
}),
|
||||
@@ -3275,7 +3475,7 @@ async function reload () {
|
||||
async function loadOrderProductCustomers (showLoading = true) {
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const resp = await api.get('/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 })
|
||||
const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 })
|
||||
orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
|
||||
orderProductCustomersLoaded.value = true
|
||||
await primeProductImages(orderProductCustomerRows.value)
|
||||
@@ -3289,7 +3489,7 @@ async function loadOrderProductCustomers (showLoading = true) {
|
||||
async function loadOrderMarketDetails (showLoading = true) {
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const resp = await api.get('/pricing/product-performance/orders/market-details', { params: { limit: 800 }, timeout: 90000 })
|
||||
const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: 800 }, timeout: 90000 })
|
||||
orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow)
|
||||
orderMarketDetailsLoaded.value = true
|
||||
} catch (err) {
|
||||
@@ -3303,9 +3503,9 @@ async function loadOrderAnalysis (showLoading = true) {
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const [productResp, marketResp, customerResp] = await Promise.all([
|
||||
api.get('/pricing/product-performance/orders', { params: { limit: 500 }, timeout: 90000 }),
|
||||
api.get('/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'market' }, timeout: 90000 }),
|
||||
api.get('/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'customer' }, timeout: 90000 })
|
||||
timedProductPerformanceGet('orders:products', '/pricing/product-performance/orders', { params: { limit: 500 }, timeout: 90000 }),
|
||||
timedProductPerformanceGet('orders:groups:market', '/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'market' }, timeout: 90000 }),
|
||||
timedProductPerformanceGet('orders:groups:customer', '/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'customer' }, timeout: 90000 })
|
||||
])
|
||||
orderAnalysisRows.value = (Array.isArray(productResp?.data) ? productResp.data : []).map(normalizeOrderAnalysisRow)
|
||||
orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow)
|
||||
@@ -3683,6 +3883,11 @@ onMounted(reload)
|
||||
left: 1170px;
|
||||
}
|
||||
|
||||
.sticky-dim-table :deep(.q-table th:nth-child(11)),
|
||||
.sticky-dim-table :deep(.q-table td:nth-child(11)) {
|
||||
left: 1300px;
|
||||
}
|
||||
|
||||
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)),
|
||||
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(-n+3)),
|
||||
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)),
|
||||
@@ -3694,7 +3899,9 @@ onMounted(reload)
|
||||
.sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(-n+9)),
|
||||
.sticky-dim-table.sticky-dim-9 :deep(.q-table td:nth-child(-n+9)),
|
||||
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)),
|
||||
.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(-n+10)) {
|
||||
.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(-n+10)),
|
||||
.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(-n+11)),
|
||||
.sticky-dim-table.sticky-dim-11 :deep(.q-table td:nth-child(-n+11)) {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -3704,12 +3911,13 @@ onMounted(reload)
|
||||
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)),
|
||||
.sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)),
|
||||
.sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(-n+9)),
|
||||
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)) {
|
||||
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)),
|
||||
.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(-n+11)) {
|
||||
z-index: 4;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.sticky-dim-table :deep(.q-table tbody td:nth-child(-n+10)) {
|
||||
.sticky-dim-table :deep(.q-table tbody td:nth-child(-n+11)) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -3724,7 +3932,9 @@ onMounted(reload)
|
||||
.sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(9)),
|
||||
.sticky-dim-table.sticky-dim-9 :deep(.q-table td:nth-child(9)),
|
||||
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(10)),
|
||||
.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(10)) {
|
||||
.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(10)),
|
||||
.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(11)),
|
||||
.sticky-dim-table.sticky-dim-11 :deep(.q-table td:nth-child(11)) {
|
||||
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,10 @@ function resolveProductImageUrl (item) {
|
||||
return fileName ? `/uploads/image/${fileName}` : ''
|
||||
}
|
||||
|
||||
function sortedList (value) {
|
||||
function normalizedList (value, sort = false) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.map(x => String(x || '').trim()).filter(Boolean).sort()
|
||||
const out = value.map(x => String(x || '').trim()).filter(Boolean)
|
||||
return sort ? out.sort() : out
|
||||
}
|
||||
|
||||
export const useProductPerformanceStore = defineStore('product-performance-store', {
|
||||
@@ -64,8 +65,8 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
groupedCacheKey (params = {}) {
|
||||
return JSON.stringify({
|
||||
mode: String(params.mode || ''),
|
||||
groupLevels: sortedList(params.groupLevels),
|
||||
expandedKeys: sortedList(params.expandedKeys),
|
||||
groupLevels: normalizedList(params.groupLevels),
|
||||
expandedKeys: normalizedList(params.expandedKeys, true),
|
||||
limit: Number(params.limit || 0)
|
||||
})
|
||||
},
|
||||
@@ -75,15 +76,40 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
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]
|
||||
const logPrefix = '[ProductPerformance][grouped]'
|
||||
if (!options.force && Array.isArray(cached) && now - loadedAt < GROUPED_TTL_MS) {
|
||||
console.info(`${logPrefix} cache hit`, {
|
||||
mode: params.mode,
|
||||
rows: cached.length,
|
||||
ageMs: now - loadedAt
|
||||
})
|
||||
return cached
|
||||
}
|
||||
if (this.groupedInFlightByKey[key]) {
|
||||
console.info(`${logPrefix} in-flight reuse`, {
|
||||
mode: params.mode,
|
||||
groupLevels: normalizedList(params.groupLevels),
|
||||
expandedKeys: normalizedList(params.expandedKeys, true).length
|
||||
})
|
||||
return this.groupedInFlightByKey[key]
|
||||
}
|
||||
|
||||
this.groupedLoadingByKey = { ...this.groupedLoadingByKey, [key]: true }
|
||||
const request = api.get('/pricing/product-performance/grouped', {
|
||||
const startedAt = performance.now()
|
||||
console.info(`${logPrefix} request start`, {
|
||||
mode: params.mode,
|
||||
groupLevels: normalizedList(params.groupLevels),
|
||||
expandedKeys: normalizedList(params.expandedKeys, true).length,
|
||||
limit: params.limit
|
||||
})
|
||||
const request = api.post('/pricing/product-performance/grouped', {
|
||||
mode: params.mode,
|
||||
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,
|
||||
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
|
||||
@@ -91,7 +117,19 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
const rows = Array.isArray(resp?.data) ? resp.data : []
|
||||
this.groupedRowsByKey = { ...this.groupedRowsByKey, [key]: rows }
|
||||
this.groupedLoadedAtByKey = { ...this.groupedLoadedAtByKey, [key]: Date.now() }
|
||||
console.info(`${logPrefix} request done`, {
|
||||
mode: params.mode,
|
||||
rows: rows.length,
|
||||
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2))
|
||||
})
|
||||
return rows
|
||||
}).catch(err => {
|
||||
console.warn(`${logPrefix} request failed`, {
|
||||
mode: params.mode,
|
||||
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)),
|
||||
message: err?.response?.data || err?.message || err
|
||||
})
|
||||
throw err
|
||||
}).finally(() => {
|
||||
const nextLoading = { ...this.groupedLoadingByKey }
|
||||
const nextInFlight = { ...this.groupedInFlightByKey }
|
||||
|
||||
Reference in New Issue
Block a user