From 1bf51b9e8287242aa5c805e3e601a9b0ae93fd89 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Thu, 2 Jul 2026 16:08:27 +0300 Subject: [PATCH] ui: add B2B olmayan stok (orphans) page --- svc/main.go | 10 + svc/models/product_performance.go | 56 ++- svc/queries/product_performance.go | 189 ++++++- svc/queries/product_performance_sql.go | 58 ++- svc/routes/product_performance.go | 50 ++ ui/.quasar/prod-spa/app.js | 75 +++ ui/.quasar/prod-spa/client-entry.js | 158 ++++++ ui/.quasar/prod-spa/client-prefetch.js | 116 +++++ ui/.quasar/prod-spa/quasar-user-options.js | 23 + .../pages/ProductPerformanceProfitability.vue | 464 ++++++++++++++++-- 10 files changed, 1126 insertions(+), 73 deletions(-) create mode 100644 ui/.quasar/prod-spa/app.js create mode 100644 ui/.quasar/prod-spa/client-entry.js create mode 100644 ui/.quasar/prod-spa/client-prefetch.js create mode 100644 ui/.quasar/prod-spa/quasar-user-options.js diff --git a/svc/main.go b/svc/main.go index 408c683..c9a6586 100644 --- a/svc/main.go +++ b/svc/main.go @@ -954,6 +954,16 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "pricing", "view", wrapV3(routes.GetProductPerformanceCustomersHandler(pgDB)), ) + bindV3(r, pgDB, + "/api/pricing/product-performance/sales-details", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceSalesDetailsHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/product-performance/stock-sizes", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceStockSizesHandler()), + ) bindV3(r, pgDB, "/api/pricing/product-performance/refresh", "POST", "pricing", "update", diff --git a/svc/models/product_performance.go b/svc/models/product_performance.go index 32bb33c..bb48273 100644 --- a/svc/models/product_performance.go +++ b/svc/models/product_performance.go @@ -17,19 +17,31 @@ type ProductPerformanceRow struct { StockQty float64 `json:"stock_qty"` SalesQty30 float64 `json:"sales_qty_30d"` SalesQty90 float64 `json:"sales_qty_90d"` + SalesQty180 float64 `json:"sales_qty_180d"` SalesQty365 float64 `json:"sales_qty_365d"` SalesQty730 float64 `json:"sales_qty_730d"` SalesUSD30 float64 `json:"sales_usd_30d"` SalesUSD90 float64 `json:"sales_usd_90d"` + SalesUSD180 float64 `json:"sales_usd_180d"` SalesUSD365 float64 `json:"sales_usd_365d"` AvgDailySales90 float64 `json:"avg_daily_sales_90d"` + AvgDailySales180 float64 `json:"avg_daily_sales_180d"` StockDays90 float64 `json:"stock_days_90d"` + StockDays180 float64 `json:"stock_days_180d"` AvgPriceUSD90 float64 `json:"avg_price_usd_90d"` + AvgPriceUSD180 float64 `json:"avg_price_usd_180d"` CostPriceUSD float64 `json:"cost_price_usd"` BasePriceUSD float64 `json:"base_price_usd"` BasePriceTRY float64 `json:"base_price_try"` GrossProfitUSD90 float64 `json:"gross_profit_usd_90d"` + GrossProfitUSD180 float64 `json:"gross_profit_usd_180d"` GrossMargin90 float64 `json:"gross_margin_90d"` + GrossMargin180 float64 `json:"gross_margin_180d"` + UnitProfitCost90 float64 `json:"unit_profit_cost_90d"` + UnitProfitCost180 float64 `json:"unit_profit_cost_180d"` + UnitProfitBase90 float64 `json:"unit_profit_base_90d"` + UnitProfitBase180 float64 `json:"unit_profit_base_180d"` + MarketCount90 int `json:"market_count_90d"` CustomerCount90 int `json:"customer_count_90d"` SalesIndex90 float64 `json:"sales_index_90d"` PriceIndex90 float64 `json:"price_index_90d"` @@ -43,18 +55,20 @@ type ProductPerformanceRow struct { } type ProductPerformanceSummary struct { - KpiDate string `json:"kpi_date"` - TotalRows int `json:"total_rows"` - StarCount int `json:"star_count"` - StockRisk int `json:"stock_risk"` - NoStockDemand int `json:"no_stock_demand"` - TotalStock float64 `json:"total_stock"` - StockCostUSD float64 `json:"stock_cost_usd"` - RiskCostUSD float64 `json:"risk_cost_usd"` - SalesQty90 float64 `json:"sales_qty_90d"` - SalesUSD90 float64 `json:"sales_usd_90d"` - GrossProfit90 float64 `json:"gross_profit_usd_90d"` - UpdatedAt string `json:"updated_at"` + KpiDate string `json:"kpi_date"` + TotalRows int `json:"total_rows"` + StarCount int `json:"star_count"` + StockRisk int `json:"stock_risk"` + NoStockDemand int `json:"no_stock_demand"` + TotalStock float64 `json:"total_stock"` + StockCostUSD float64 `json:"stock_cost_usd"` + RiskCostUSD float64 `json:"risk_cost_usd"` + SalesQty90 float64 `json:"sales_qty_90d"` + SalesUSD90 float64 `json:"sales_usd_90d"` + GrossProfit90 float64 `json:"gross_profit_usd_90d"` + MarketCount90 int `json:"market_count_90d"` + CustomerCount90 int `json:"customer_count_90d"` + UpdatedAt string `json:"updated_at"` } type ProductPerformanceMarketRow struct { @@ -111,3 +125,21 @@ type ProductPerformanceCustomerRow struct { SalesUSD365 float64 `json:"sales_usd_365d"` LastSaleDate string `json:"last_sale_date"` } + +type ProductPerformanceSalesDetailRow struct { + SalesDate string `json:"sales_date"` + RefNumber string `json:"ref_number"` + MarketKey string `json:"market_key"` + Country string `json:"country"` + CustomerSegment string `json:"customer_segment"` + CustomerCode string `json:"customer_code"` + CustomerName string `json:"customer_name"` + SalesQty float64 `json:"sales_qty"` + SalesUSD float64 `json:"sales_usd"` + AvgPriceUSD float64 `json:"avg_price_usd"` +} + +type ProductPerformanceStockSizeRow struct { + SizeCode string `json:"size_code"` + StockQty float64 `json:"stock_qty"` +} diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index 173d4fa..7854cc6 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -18,6 +18,8 @@ type ProductPerformanceFilters struct { Kategori string Seri string Bucket string + SortBy string + Descending bool Limit int Page int } @@ -149,19 +151,31 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily ( stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0, sales_qty_30d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_qty_90d NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_qty_180d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_qty_365d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_qty_730d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_usd_30d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_usd_365d NUMERIC(18,4) NOT NULL DEFAULT 0, avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0, stock_days_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0, avg_price_usd_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + avg_price_usd_180d NUMERIC(18,6) NOT NULL DEFAULT 0, cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0, gross_profit_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0, + gross_profit_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0, gross_margin_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + gross_margin_180d NUMERIC(18,6) NOT NULL DEFAULT 0, + unit_profit_cost_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + unit_profit_cost_180d NUMERIC(18,6) NOT NULL DEFAULT 0, + unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0, + market_count_90d INTEGER NOT NULL DEFAULT 0, customer_count_90d INTEGER NOT NULL DEFAULT 0, sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, price_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, @@ -181,6 +195,18 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily ( `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_qty_180d NUMERIC(18,4) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_price_usd_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS gross_profit_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS gross_margin_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_cost_90d NUMERIC(18,6) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_cost_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_90d INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range stmts { if _, err := pg.Exec(stmt); err != nil { @@ -631,20 +657,22 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc return nil, 0, err } args = append(args, limit, (page-1)*limit) + orderBy := productPerformanceOrderBy(f.SortBy, f.Descending) rows, err := pg.QueryContext(ctx, ` SELECT to_char(kpi_date,'YYYY-MM-DD'), product_code, color_code, yaka_kodu, item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, - sales_qty_30d, sales_qty_90d, sales_qty_365d, sales_qty_730d, - sales_usd_30d, sales_usd_90d, sales_usd_365d, avg_daily_sales_90d, - stock_days_90d, avg_price_usd_90d, cost_price_usd, base_price_usd, base_price_try, - gross_profit_usd_90d, gross_margin_90d, customer_count_90d, sales_index_90d, + sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, + sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, avg_daily_sales_90d, avg_daily_sales_180d, + stock_days_90d, stock_days_180d, avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try, + gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d, + unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d, market_count_90d, customer_count_90d, sales_index_90d, price_index_90d, margin_index_90d, performance_score, performance_bucket, recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number, to_char(updated_at,'YYYY-MM-DD HH24:MI:SS') FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)`+where+` -ORDER BY performance_score DESC, sales_qty_90d DESC, product_code +ORDER BY `+orderBy+` LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) if err != nil { return nil, 0, err @@ -656,10 +684,11 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) if err := rows.Scan( &r.KpiDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.StockQty, - &r.SalesQty30, &r.SalesQty90, &r.SalesQty365, &r.SalesQty730, - &r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD365, &r.AvgDailySales90, - &r.StockDays90, &r.AvgPriceUSD90, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY, - &r.GrossProfitUSD90, &r.GrossMargin90, &r.CustomerCount90, &r.SalesIndex90, + &r.SalesQty30, &r.SalesQty90, &r.SalesQty180, &r.SalesQty365, &r.SalesQty730, + &r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.AvgDailySales90, &r.AvgDailySales180, + &r.StockDays90, &r.StockDays180, &r.AvgPriceUSD90, &r.AvgPriceUSD180, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY, + &r.GrossProfitUSD90, &r.GrossProfitUSD180, &r.GrossMargin90, &r.GrossMargin180, + &r.UnitProfitCost90, &r.UnitProfitCost180, &r.UnitProfitBase90, &r.UnitProfitBase180, &r.MarketCount90, &r.CustomerCount90, &r.SalesIndex90, &r.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt, ); err != nil { @@ -676,6 +705,22 @@ func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.Produ } var s models.ProductPerformanceSummary err := pg.QueryRowContext(ctx, ` +WITH Latest AS ( + SELECT MAX(kpi_date) AS kpi_date + FROM mk_product_performance_kpi_daily +), +KPI AS ( + SELECT * + FROM mk_product_performance_kpi_daily + WHERE kpi_date = (SELECT kpi_date FROM Latest) +), +Sales90 AS ( + SELECT + COUNT(DISTINCT market_key) AS market_count_90d, + COUNT(DISTINCT NULLIF(customer_code, '-')) AS customer_count_90d + FROM mk_product_performance_sales_daily + WHERE sales_date BETWEEN (SELECT kpi_date FROM Latest) - INTERVAL '89 days' AND (SELECT kpi_date FROM Latest) +) SELECT COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''), COUNT(*), @@ -688,10 +733,12 @@ SELECT COALESCE(SUM(sales_qty_90d),0), COALESCE(SUM(sales_usd_90d),0), COALESCE(SUM(gross_profit_usd_90d),0), + COALESCE(MAX(Sales90.market_count_90d),0)::integer, + COALESCE(MAX(Sales90.customer_count_90d),0)::integer, COALESCE(to_char(MAX(updated_at),'YYYY-MM-DD HH24:MI:SS'),'') -FROM mk_product_performance_kpi_daily -WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) -`).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.UpdatedAt) +FROM KPI +CROSS JOIN Sales90 +`).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.MarketCount90, &s.CustomerCount90, &s.UpdatedAt) return s, err } @@ -879,6 +926,76 @@ LIMIT $1 return out, rows.Err() } +func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := pg.QueryContext(ctx, ` +SELECT + to_char(sales_date,'YYYY-MM-DD') AS sales_date, + last_ref_number, + market_key, + customer_country, + customer_segment, + customer_code, + customer_name, + sales_qty, + sales_usd, + avg_price_usd +FROM mk_product_performance_sales_daily +WHERE product_code=$1 + AND color_code=$2 + AND yaka_kodu=$3 +ORDER BY sales_date DESC, sales_usd DESC +LIMIT $4 +`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.ProductPerformanceSalesDetailRow, 0, limit) + for rows.Next() { + var r models.ProductPerformanceSalesDetailRow + if err := rows.Scan(&r.SalesDate, &r.RefNumber, &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &r.SalesQty, &r.SalesUSD, &r.AvgPriceUSD); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func ListProductPerformanceStockSizes(ctx context.Context, productCode, colorCode, yakaKodu string) ([]models.ProductPerformanceStockSizeRow, error) { + if db.MssqlDB == nil { + return nil, fmt.Errorf("mssql db nil") + } + rows, err := db.MssqlDB.QueryContext(ctx, ` +SELECT + SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))), + StockQty = SUM(S.InventoryQty1) +FROM StockWithCost S WITH(NOLOCK) +WHERE S.ItemTypeCode = 1 + AND LTRIM(RTRIM(S.ItemCode)) = @p1 + AND LTRIM(RTRIM(ISNULL(S.ColorCode, ''))) = @p2 + AND LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))) = @p3 +GROUP BY LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))) +HAVING SUM(S.InventoryQty1) <> 0 +ORDER BY LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))) +`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu)) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.ProductPerformanceStockSizeRow, 0, 32) + for rows.Next() { + var r models.ProductPerformanceStockSizeRow + if err := rows.Scan(&r.SizeCode, &r.StockQty); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) { parts := make([]string, 0, 6) args := make([]any, 0, 6) @@ -911,6 +1028,54 @@ func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) { return strings.Join(parts, ""), args } +func productPerformanceOrderBy(sortBy string, desc bool) string { + allowed := map[string]string{ + "product_code": "product_code", + "color_code": "color_code", + "yaka_kodu": "yaka_kodu", + "item_description": "item_description", + "kategori": "kategori", + "urun_ilk_grubu": "urun_ilk_grubu", + "askili_yan": "askili_yan", + "urun_ana_grubu": "urun_ana_grubu", + "urun_alt_grubu": "urun_alt_grubu", + "market_key": "market_key", + "stock_qty": "stock_qty", + "sales_qty_90d": "sales_qty_90d", + "sales_qty_180d": "sales_qty_180d", + "sales_usd_90d": "sales_usd_90d", + "sales_usd_180d": "sales_usd_180d", + "stock_days_90d": "stock_days_90d", + "stock_days_180d": "stock_days_180d", + "avg_price_usd_90d": "avg_price_usd_90d", + "avg_price_usd_180d": "avg_price_usd_180d", + "base_price_usd": "base_price_usd", + "cost_price_usd": "cost_price_usd", + "unit_profit_base_90d": "unit_profit_base_90d", + "unit_profit_cost_90d": "unit_profit_cost_90d", + "unit_profit_base_180d": "unit_profit_base_180d", + "unit_profit_cost_180d": "unit_profit_cost_180d", + "gross_margin_90d": "gross_margin_90d", + "gross_margin_180d": "gross_margin_180d", + "market_count_90d": "market_count_90d", + "customer_count_90d": "customer_count_90d", + "sales_index_90d": "sales_index_90d", + "performance_score": "performance_score", + "performance_bucket": "performance_bucket", + "last_sale_date": "last_sale_date", + } + col := allowed[strings.TrimSpace(sortBy)] + if col == "" { + col = "performance_score" + desc = true + } + dir := "ASC" + if desc { + dir = "DESC" + } + return col + " " + dir + ", product_code ASC, color_code ASC, yaka_kodu ASC" +} + func dateOnly(t time.Time) time.Time { y, m, d := t.Date() return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) diff --git a/svc/queries/product_performance_sql.go b/svc/queries/product_performance_sql.go index 94374c9..f25a0ec 100644 --- a/svc/queries/product_performance_sql.go +++ b/svc/queries/product_performance_sql.go @@ -422,12 +422,14 @@ SalesAgg AS ( MAX(urun_alt_grubu) AS urun_alt_grubu, SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '29 days') AS sales_qty_30d, SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS sales_qty_90d, + SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS sales_qty_180d, SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '364 days') AS sales_qty_365d, SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '729 days') AS sales_qty_730d, SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '29 days') AS sales_usd_30d, SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS sales_usd_90d, + SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS sales_usd_180d, SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '364 days') AS sales_usd_365d, - SUM(customer_count) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS customer_count_90d, + COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS customer_count_90d, MAX(sales_date) AS last_sale_date, MAX(last_ref_number) AS last_ref_number FROM mk_product_performance_sales_daily @@ -439,8 +441,8 @@ Scope AS ( SELECT product_code, color_code, yaka_kodu, market_key, item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, - sales_qty_30d, sales_qty_90d, sales_qty_365d, sales_qty_730d, - sales_usd_30d, sales_usd_90d, sales_usd_365d, + sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, + sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, customer_count_90d, last_sale_date, last_ref_number FROM SalesAgg UNION ALL @@ -459,10 +461,12 @@ Scope AS ( '' AS urun_alt_grubu, 0 AS sales_qty_30d, 0 AS sales_qty_90d, + 0 AS sales_qty_180d, 0 AS sales_qty_365d, 0 AS sales_qty_730d, 0 AS sales_usd_30d, 0 AS sales_usd_90d, + 0 AS sales_usd_180d, 0 AS sales_usd_365d, 0 AS customer_count_90d, NULL::date AS last_sale_date, @@ -485,15 +489,35 @@ Base AS ( COALESCE(pd.base_price_usd, 0) AS base_price_usd, COALESCE(pd.base_price_try, 0) AS base_price_try, COALESCE(s.sales_qty_90d, 0) / 90.0 AS avg_daily_sales_90d, + COALESCE(s.sales_qty_180d, 0) / 180.0 AS avg_daily_sales_180d, CASE WHEN COALESCE(s.sales_qty_90d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_90d, 0) / NULLIF(s.sales_qty_90d, 0) END AS avg_price_usd_90d, + CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0) END AS avg_price_usd_180d, COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_90d, + COALESCE(s.sales_usd_180d, 0) - (COALESCE(s.sales_qty_180d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_180d, CASE WHEN COALESCE(s.sales_usd_90d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0))) / NULLIF(s.sales_usd_90d, 0) - END AS gross_margin_90d + END AS gross_margin_90d, + CASE WHEN COALESCE(s.sales_usd_180d, 0) = 0 THEN 0 + ELSE (COALESCE(s.sales_usd_180d, 0) - (COALESCE(s.sales_qty_180d, 0) * COALESCE(pd.cost_price_usd, 0))) / NULLIF(s.sales_usd_180d, 0) + END AS gross_margin_180d, + CASE WHEN COALESCE(s.sales_qty_90d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_90d, 0) / NULLIF(s.sales_qty_90d, 0)) - COALESCE(pd.cost_price_usd, 0) END AS unit_profit_cost_90d, + CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0)) - COALESCE(pd.cost_price_usd, 0) END AS unit_profit_cost_180d, + CASE WHEN COALESCE(s.sales_qty_90d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_90d, 0) / NULLIF(s.sales_qty_90d, 0)) - COALESCE(pd.base_price_usd, 0) END AS unit_profit_base_90d, + CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0)) - COALESCE(pd.base_price_usd, 0) END AS unit_profit_base_180d FROM Scope s LEFT JOIN LatestStock ls ON ls.product_code=s.product_code AND ls.color_code=s.color_code AND ls.yaka_kodu=s.yaka_kodu LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=s.product_code ), +Spread AS ( + SELECT + product_code, + color_code, + yaka_kodu, + COUNT(DISTINCT market_key) AS market_count_90d + FROM SalesAgg + WHERE COALESCE(sales_qty_90d, 0) > 0 + GROUP BY product_code, color_code, yaka_kodu +), Market AS ( SELECT market_key, @@ -506,20 +530,24 @@ Market AS ( Scored AS ( SELECT b.*, + COALESCE(sp.market_count_90d, 0) AS market_count_90d, CASE WHEN b.avg_daily_sales_90d = 0 THEN 0 ELSE b.stock_qty / NULLIF(b.avg_daily_sales_90d, 0) END AS stock_days_90d, + CASE WHEN b.avg_daily_sales_180d = 0 THEN 0 ELSE b.stock_qty / NULLIF(b.avg_daily_sales_180d, 0) END AS stock_days_180d, CASE WHEN COALESCE(m.market_avg_sales_qty_90d, 0) = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(m.market_avg_sales_qty_90d, 0) END AS sales_index_90d, CASE WHEN COALESCE(m.market_avg_price_usd_90d, 0) = 0 THEN 0 ELSE b.avg_price_usd_90d / NULLIF(m.market_avg_price_usd_90d, 0) END AS price_index_90d, CASE WHEN COALESCE(m.market_avg_margin_90d, 0) = 0 THEN 0 ELSE b.gross_margin_90d / NULLIF(m.market_avg_margin_90d, 0) END AS margin_index_90d FROM Base b LEFT JOIN Market m ON m.market_key=b.market_key + LEFT JOIN Spread sp ON sp.product_code=b.product_code AND sp.color_code=b.color_code AND sp.yaka_kodu=b.yaka_kodu ) INSERT INTO mk_product_performance_kpi_daily ( kpi_date, product_code, color_code, yaka_kodu, item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, - sales_qty_30d, sales_qty_90d, sales_qty_365d, sales_qty_730d, - sales_usd_30d, sales_usd_90d, sales_usd_365d, avg_daily_sales_90d, - stock_days_90d, avg_price_usd_90d, cost_price_usd, base_price_usd, base_price_try, - gross_profit_usd_90d, gross_margin_90d, customer_count_90d, + sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, + sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, avg_daily_sales_90d, avg_daily_sales_180d, + stock_days_90d, stock_days_180d, avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try, + gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d, + unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d, market_count_90d, customer_count_90d, sales_index_90d, price_index_90d, margin_index_90d, performance_score, performance_bucket, recommendation, last_sale_date, last_ref_number, updated_at ) @@ -527,16 +555,18 @@ SELECT $1::date, product_code, color_code, yaka_kodu, item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, - COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0), - COALESCE(sales_usd_30d, 0), COALESCE(sales_usd_90d, 0), COALESCE(sales_usd_365d, 0), COALESCE(avg_daily_sales_90d, 0), - COALESCE(stock_days_90d, 0), COALESCE(avg_price_usd_90d, 0), COALESCE(cost_price_usd, 0), COALESCE(base_price_usd, 0), COALESCE(base_price_try, 0), - COALESCE(gross_profit_usd_90d, 0), COALESCE(gross_margin_90d, 0), COALESCE(customer_count_90d, 0), + COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_180d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0), + COALESCE(sales_usd_30d, 0), COALESCE(sales_usd_90d, 0), COALESCE(sales_usd_180d, 0), COALESCE(sales_usd_365d, 0), COALESCE(avg_daily_sales_90d, 0), COALESCE(avg_daily_sales_180d, 0), + COALESCE(stock_days_90d, 0), COALESCE(stock_days_180d, 0), COALESCE(avg_price_usd_90d, 0), COALESCE(avg_price_usd_180d, 0), COALESCE(cost_price_usd, 0), COALESCE(base_price_usd, 0), COALESCE(base_price_try, 0), + COALESCE(gross_profit_usd_90d, 0), COALESCE(gross_profit_usd_180d, 0), COALESCE(gross_margin_90d, 0), COALESCE(gross_margin_180d, 0), + COALESCE(unit_profit_cost_90d, 0), COALESCE(unit_profit_cost_180d, 0), COALESCE(unit_profit_base_90d, 0), COALESCE(unit_profit_base_180d, 0), COALESCE(market_count_90d, 0), COALESCE(customer_count_90d, 0), COALESCE(sales_index_90d, 0), COALESCE(price_index_90d, 0), COALESCE(margin_index_90d, 0), ROUND( - LEAST(40, COALESCE(sales_index_90d, 0) * 20) + LEAST(35, COALESCE(sales_index_90d, 0) * 18) + LEAST(25, GREATEST(COALESCE(gross_margin_90d, 0), 0) * 50) + LEAST(20, COALESCE(customer_count_90d, 0) * 2) - + CASE WHEN COALESCE(stock_days_90d, 0) BETWEEN 10 AND 90 THEN 15 WHEN COALESCE(stock_days_90d, 0) > 180 THEN -10 ELSE 0 END + + LEAST(10, COALESCE(market_count_90d, 0) * 3) + + CASE WHEN COALESCE(stock_days_90d, 0) BETWEEN 10 AND 90 THEN 10 WHEN COALESCE(stock_days_90d, 0) > 180 THEN -10 ELSE 0 END , 4) AS performance_score, CASE WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'YILDIZ_URUN' diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go index 069a0c9..4ccec18 100644 --- a/svc/routes/product_performance.go +++ b/svc/routes/product_performance.go @@ -26,6 +26,8 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc { Kategori: strings.TrimSpace(r.URL.Query().Get("kategori")), Seri: strings.TrimSpace(r.URL.Query().Get("seri")), Bucket: strings.TrimSpace(r.URL.Query().Get("bucket")), + SortBy: strings.TrimSpace(r.URL.Query().Get("sort_by")), + Descending: boolQuery(r, "descending", true), Limit: intQuery(r, "limit", 100), Page: intQuery(r, "page", 1), } @@ -44,6 +46,46 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc { } } +func GetProductPerformanceSalesDetailsHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + rows, err := queries.ListProductPerformanceSalesDetails( + ctx, + pg, + strings.TrimSpace(r.URL.Query().Get("product_code")), + strings.TrimSpace(r.URL.Query().Get("color_code")), + strings.TrimSpace(r.URL.Query().Get("yaka_kodu")), + intQuery(r, "limit", 100), + ) + if err != nil { + http.Error(w, "urun satis detayi alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +func GetProductPerformanceStockSizesHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + rows, err := queries.ListProductPerformanceStockSizes( + ctx, + strings.TrimSpace(r.URL.Query().Get("product_code")), + strings.TrimSpace(r.URL.Query().Get("color_code")), + strings.TrimSpace(r.URL.Query().Get("yaka_kodu")), + ) + if err != nil { + http.Error(w, "urun beden stok detayi alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + func GetProductPerformanceSummaryHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -166,6 +208,14 @@ func intQuery(r *http.Request, key string, fallback int) int { return n } +func boolQuery(r *http.Request, key string, fallback bool) bool { + raw := strings.TrimSpace(strings.ToLower(r.URL.Query().Get(key))) + if raw == "" { + return fallback + } + return raw == "1" || raw == "true" || raw == "yes" || raw == "on" +} + func parseDateOrZero(raw string) time.Time { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/ui/.quasar/prod-spa/app.js b/ui/.quasar/prod-spa/app.js new file mode 100644 index 0000000..caeaac1 --- /dev/null +++ b/ui/.quasar/prod-spa/app.js @@ -0,0 +1,75 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " and add it there. + * One boot file per concern. Then reference the file(s) in quasar.config file > boot: + * boot: ['file', ...] // do not add ".js" extension to it. + * + * Boot files are your "main.js" + **/ + + + + + +import { Quasar } from 'quasar' +import { markRaw } from 'vue' +import RootComponent from 'app/src/App.vue' + +import createStore from 'app/src/stores/index' +import createRouter from 'app/src/router/index' + + + + + +export default async function (createAppFn, quasarUserOptions) { + + + // Create the app instance. + // Here we inject into it the Quasar UI, the router & possibly the store. + const app = createAppFn(RootComponent) + + + + app.use(Quasar, quasarUserOptions) + + + + + const store = typeof createStore === 'function' + ? await createStore({}) + : createStore + + + app.use(store) + + + + + + const router = markRaw( + typeof createRouter === 'function' + ? await createRouter({store}) + : createRouter + ) + + + // make router instance available in store + + store.use(({ store }) => { store.router = router }) + + + + // Expose the app, the router and the store. + // Note that we are not mounting the app here, since bootstrapping will be + // different depending on whether we are in a browser or on the server. + return { + app, + store, + router + } +} diff --git a/ui/.quasar/prod-spa/client-entry.js b/ui/.quasar/prod-spa/client-entry.js new file mode 100644 index 0000000..5223e2b --- /dev/null +++ b/ui/.quasar/prod-spa/client-entry.js @@ -0,0 +1,158 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " and add it there. + * One boot file per concern. Then reference the file(s) in quasar.config file > boot: + * boot: ['file', ...] // do not add ".js" extension to it. + * + * Boot files are your "main.js" + **/ + + +import { createApp } from 'vue' + + + + + + + +import '@quasar/extras/roboto-font/roboto-font.css' + +import '@quasar/extras/material-icons/material-icons.css' + + + + +// We load Quasar stylesheet file +import 'quasar/dist/quasar.sass' + + + + +import 'src/css/app.css' + + +import createQuasarApp from './app.js' +import quasarUserOptions from './quasar-user-options.js' + + + + + + + + +const publicPath = `/` + + +async function start ({ + app, + router + , store +}, bootFiles) { + + let hasRedirected = false + const getRedirectUrl = url => { + try { return router.resolve(url).href } + catch (err) {} + + return Object(url) === url + ? null + : url + } + const redirect = url => { + hasRedirected = true + + if (typeof url === 'string' && /^https?:\/\//.test(url)) { + window.location.href = url + return + } + + const href = getRedirectUrl(url) + + // continue if we didn't fail to resolve the url + if (href !== null) { + window.location.href = href + window.location.reload() + } + } + + const urlPath = window.location.href.replace(window.location.origin, '') + + for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) { + try { + await bootFiles[i]({ + app, + router, + store, + ssrContext: null, + redirect, + urlPath, + publicPath + }) + } + catch (err) { + if (err && err.url) { + redirect(err.url) + return + } + + console.error('[Quasar] boot error:', err) + return + } + } + + if (hasRedirected === true) return + + + app.use(router) + + + + + + + app.mount('#q-app') + + + +} + +createQuasarApp(createApp, quasarUserOptions) + + .then(app => { + // eventually remove this when Cordova/Capacitor/Electron support becomes old + const [ method, mapFn ] = Promise.allSettled !== void 0 + ? [ + 'allSettled', + bootFiles => bootFiles.map(result => { + if (result.status === 'rejected') { + console.error('[Quasar] boot error:', result.reason) + return + } + return result.value.default + }) + ] + : [ + 'all', + bootFiles => bootFiles.map(entry => entry.default) + ] + + return Promise[ method ]([ + + import(/* webpackMode: "eager" */ 'boot/dayjs'), + + import(/* webpackMode: "eager" */ 'boot/locale'), + + import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard') + + ]).then(bootFiles => { + const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function') + start(app, boot) + }) + }) + diff --git a/ui/.quasar/prod-spa/client-prefetch.js b/ui/.quasar/prod-spa/client-prefetch.js new file mode 100644 index 0000000..9bbe3c5 --- /dev/null +++ b/ui/.quasar/prod-spa/client-prefetch.js @@ -0,0 +1,116 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " and add it there. + * One boot file per concern. Then reference the file(s) in quasar.config file > boot: + * boot: ['file', ...] // do not add ".js" extension to it. + * + * Boot files are your "main.js" + **/ + + + +import App from 'app/src/App.vue' +let appPrefetch = typeof App.preFetch === 'function' + ? App.preFetch + : ( + // Class components return the component options (and the preFetch hook) inside __c property + App.__c !== void 0 && typeof App.__c.preFetch === 'function' + ? App.__c.preFetch + : false + ) + + +function getMatchedComponents (to, router) { + const route = to + ? (to.matched ? to : router.resolve(to).route) + : router.currentRoute.value + + if (!route) { return [] } + + const matched = route.matched.filter(m => m.components !== void 0) + + if (matched.length === 0) { return [] } + + return Array.prototype.concat.apply([], matched.map(m => { + return Object.keys(m.components).map(key => { + const comp = m.components[key] + return { + path: m.path, + c: comp + } + }) + })) +} + +export function addPreFetchHooks ({ router, store, publicPath }) { + // Add router hook for handling preFetch. + // Doing it after initial route is resolved so that we don't double-fetch + // the data that we already have. Using router.beforeResolve() so that all + // async components are resolved. + router.beforeResolve((to, from, next) => { + const + urlPath = window.location.href.replace(window.location.origin, ''), + matched = getMatchedComponents(to, router), + prevMatched = getMatchedComponents(from, router) + + let diffed = false + const preFetchList = matched + .filter((m, i) => { + return diffed || (diffed = ( + !prevMatched[i] || + prevMatched[i].c !== m.c || + m.path.indexOf('/:') > -1 // does it has params? + )) + }) + .filter(m => m.c !== void 0 && ( + typeof m.c.preFetch === 'function' + // Class components return the component options (and the preFetch hook) inside __c property + || (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function') + )) + .map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch) + + + if (appPrefetch !== false) { + preFetchList.unshift(appPrefetch) + appPrefetch = false + } + + + if (preFetchList.length === 0) { + return next() + } + + let hasRedirected = false + const redirect = url => { + hasRedirected = true + next(url) + } + const proceed = () => { + + if (hasRedirected === false) { next() } + } + + + + preFetchList.reduce( + (promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({ + store, + currentRoute: to, + previousRoute: from, + redirect, + urlPath, + publicPath + })), + Promise.resolve() + ) + .then(proceed) + .catch(e => { + console.error(e) + proceed() + }) + }) +} diff --git a/ui/.quasar/prod-spa/quasar-user-options.js b/ui/.quasar/prod-spa/quasar-user-options.js new file mode 100644 index 0000000..ac1dae3 --- /dev/null +++ b/ui/.quasar/prod-spa/quasar-user-options.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " and add it there. + * One boot file per concern. Then reference the file(s) in quasar.config file > boot: + * boot: ['file', ...] // do not add ".js" extension to it. + * + * Boot files are your "main.js" + **/ + +import lang from 'quasar/lang/tr.js' + + + +import {Loading,Dialog,Notify} from 'quasar' + + + +export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} } + diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 294675f..6cd2d83 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -85,48 +85,83 @@ bordered row-key="row_key" class="performance-table bg-white" - :rows="rows" + :rows="productTableRows" :columns="columns" :loading="loading" :pagination="pagination" @request="onRequest" > - - - - - - @@ -336,6 +425,13 @@ const imageDialogUrls = ref([]) const imageDialogTitle = ref('') const imageDialogSubtitle = ref('') const imageSlide = ref(0) +const performanceDialog = ref(false) +const performanceDialogRow = ref(null) +const performanceDialogTitle = ref('') +const performanceDialogSubtitle = ref('') +const detailLoading = ref(false) +const detailSalesRows = ref([]) +const detailStockSizes = ref([]) const filters = reactive({ q: '', @@ -344,6 +440,8 @@ const filters = reactive({ seri: '', bucket: '' }) +const columnFilters = reactive({}) +const expandedGroups = ref({}) const pagination = ref({ page: 1, @@ -368,6 +466,23 @@ const customerBreakdownOptions = [ { label: 'Piyasa > Ülke > Müşteri', value: 'market_country_customer' } ] +const detailStockColumns = [ + { name: 'size_code', label: 'Beden', field: 'size_code', align: 'left', sortable: true }, + { name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true } +] + +const detailSalesColumns = [ + { name: 'sales_date', label: 'Tarih', field: 'sales_date', align: 'left', sortable: true }, + { name: 'ref_number', label: 'Ref', field: 'ref_number', align: 'left', sortable: true }, + { name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true }, + { name: 'country', label: 'Ülke', field: 'country', 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: 'sales_qty', label: 'Adet', field: row => formatNumber(row.sales_qty, 0), align: 'right', sortable: true }, + { name: 'sales_usd', label: 'USD', field: row => formatMoney(row.sales_usd, 'USD'), align: 'right', sortable: true }, + { name: 'avg_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd, 'USD'), align: 'right', sortable: true } +] + const columns = [ { name: 'image', label: 'Foto', field: 'image', align: 'center' }, { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, @@ -379,12 +494,26 @@ const columns = [ { name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', 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: 'market_key', label: 'Piyasa', field: '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 }, + { name: 'sales_qty_180d', label: '180G Satış', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true }, + { name: 'sales_usd_180d', label: '180G USD', field: 'sales_usd_180d', align: 'right', sortable: true }, { name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true }, + { name: 'stock_days_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 1), align: 'right', sortable: true }, { name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' }, + { name: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' }, + { name: 'base_price_usd', label: 'Ort. USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true }, + { name: 'cost_price_usd', label: 'Ort. USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true }, + { name: 'unit_profit_base_90d', label: '90G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_90d, 'USD'), align: 'right', sortable: true }, + { name: 'unit_profit_cost_90d', label: '90G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_90d, 'USD'), align: 'right', sortable: true }, + { name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true }, + { name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true }, { name: 'gross_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true }, + { name: 'gross_margin_180d', label: '180G Marj', field: 'gross_margin_180d', align: 'right', sortable: true }, + { name: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 0), align: 'right', sortable: true }, + { name: 'customer_count_90d', label: '90G Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true }, { name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true }, { 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' }, @@ -465,6 +594,8 @@ const summaryCards = computed(() => [ { key: 'rows', label: 'Satır', value: formatNumber(summary.value.total_rows, 0) }, { key: 'star', label: 'Yıldız', value: formatNumber(summary.value.star_count, 0) }, { key: 'risk', label: 'Stok Riski', value: formatNumber(summary.value.stock_risk, 0) }, + { key: 'markets', label: '90G Piyasa', value: formatNumber(summary.value.market_count_90d, 0) }, + { key: 'customers', label: '90G Müşteri', value: formatNumber(summary.value.customer_count_90d, 0) }, { key: 'stock_cost', label: 'Stok Maliyeti', value: formatMoney(summary.value.stock_cost_usd, 'USD') }, { key: 'risk_cost', label: 'Risk Maliyeti', value: formatMoney(summary.value.risk_cost_usd, 'USD') } ]) @@ -481,6 +612,203 @@ const idleRows = computed(() => rows.value )) .sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0))) +const performanceCards = computed(() => { + const row = performanceDialogRow.value || {} + return [ + { key: 'score', label: 'Skor', value: formatNumber(row.performance_score, 1) }, + { key: 'stock', label: 'Stok', value: formatNumber(row.stock_qty, 0) }, + { key: 'sales90', label: '90G Satış', value: formatNumber(row.sales_qty_90d, 0) }, + { key: 'sales180', label: '180G Satış', value: formatNumber(row.sales_qty_180d, 0) }, + { key: 'markets', label: '90G Piyasa', value: formatNumber(row.market_count_90d, 0) }, + { key: 'customers', label: '90G Müşteri', value: formatNumber(row.customer_count_90d, 0) }, + { key: 'avg', label: 'Ort. USD Satış', value: formatMoney(row.avg_price_usd_90d, 'USD') }, + { key: 'base', label: 'Taban Maliyet', value: formatMoney(row.base_price_usd, 'USD') }, + { key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(row.cost_price_usd, 'USD') }, + { key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(row.unit_profit_base_90d, 'USD') }, + { key: 'profitCost', label: 'P.Başı Çıplak K/Z', value: formatMoney(row.unit_profit_cost_90d, 'USD') }, + { key: 'margin', label: '90G Marj', value: formatPercent(row.gross_margin_90d) }, + { key: 'bucket', label: 'Durum', value: bucketLabel(row.performance_bucket) } + ] +}) + +const filterableColumnNames = new Set([ + 'product_code', + 'color_code', + 'yaka_kodu', + 'item_description', + 'kategori', + 'urun_ilk_grubu', + 'askili_yan', + 'urun_ana_grubu', + 'urun_alt_grubu', + 'market_key', + 'performance_bucket', + 'recommendation' +]) + +const groupLevels = [ + { key: 'kategori', label: 'Kategori' }, + { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' }, + { key: 'askili_yan', label: 'Askılı/Yan' }, + { 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' }, + { key: 'color_code', label: 'Renk' } +] + +const filteredProductRows = computed(() => rows.value.filter(row => { + return columns.every(col => { + if (!isColumnFilterable(col.name)) return true + const needle = String(columnFilters[col.name] || '').trim().toLocaleLowerCase('tr-TR') + if (!needle) return true + return String(rawProductCellValue(row, col.name) || '').toLocaleLowerCase('tr-TR').includes(needle) + }) +})) + +const productTableRows = computed(() => { + const out = [] + appendGroupRows(out, filteredProductRows.value, 0, []) + return out +}) + +function appendGroupRows (out, sourceRows, level, parentKeys) { + if (level >= groupLevels.length) { + out.push(...sourceRows) + return + } + + const groupDef = groupLevels[level] + const grouped = new Map() + for (const row of sourceRows) { + const value = normalizeGroupValue(rawProductCellValue(row, groupDef.key) || row[groupDef.fallback]) + if (!grouped.has(value)) grouped.set(value, []) + grouped.get(value).push(row) + } + + Array.from(grouped.entries()) + .sort((a, b) => a[0].localeCompare(b[0], 'tr')) + .forEach(([value, groupRows]) => { + const key = [...parentKeys, `${groupDef.key}:${value}`].join('|') + out.push(makeGroupRow(key, level, `${groupDef.label}: ${value}`, groupRows)) + if (isGroupExpanded(key)) { + appendGroupRows(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`]) + } + }) +} + +function makeGroupRow (key, level, label, groupRows) { + return { + __group: true, + row_key: `group|${key}`, + key, + level, + label, + count: groupRows.length, + stock_qty: sumRows(groupRows, 'stock_qty'), + sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'), + sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'), + sales_usd_90d: sumRows(groupRows, 'sales_usd_90d'), + sales_usd_180d: sumRows(groupRows, 'sales_usd_180d'), + market_count_90d: distinctCount(groupRows, 'market_key'), + customer_count_90d: sumRows(groupRows, 'customer_count_90d'), + avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'), + avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'), + avg_base_price_usd: averageRows(groupRows, 'base_price_usd'), + avg_cost_price_usd: averageRows(groupRows, 'cost_price_usd'), + avg_gross_margin_90d: averageRows(groupRows, 'gross_margin_90d') + } +} + +function sumRows (sourceRows, field) { + return sourceRows.reduce((sum, row) => sum + Number(row[field] || 0), 0) +} + +function distinctCount (sourceRows, field) { + const values = new Set() + for (const row of sourceRows) { + const value = String(row[field] || '').trim() + if (value && value !== '-') values.add(value) + } + return values.size +} + +function averageRows (sourceRows, field) { + const values = sourceRows.map(row => Number(row[field] || 0)).filter(value => Number.isFinite(value) && value !== 0) + if (!values.length) return 0 + return values.reduce((sum, value) => sum + value, 0) / values.length +} + +function weightedAverage (sourceRows, amountField, qtyField) { + const qty = sumRows(sourceRows, qtyField) + if (!qty) return 0 + return sumRows(sourceRows, amountField) / qty +} + +function normalizeGroupValue (value) { + const text = String(value || '').trim() + return text || '-' +} + +function isGroupExpanded (key) { + return expandedGroups.value[key] === true +} + +function toggleGroup (key) { + expandedGroups.value = { + ...expandedGroups.value, + [key]: !isGroupExpanded(key) + } +} + +function isColumnFilterable (name) { + return filterableColumnNames.has(name) +} + +function rawProductCellValue (row, name) { + switch (name) { + case 'urun_ilk_grubu': return row.urun_ilk_grubu || row.yas_grubu || '' + case 'urun_ana_grubu': return row.urun_ana_grubu || row.seri || '' + case 'performance_bucket': return bucketLabel(row.performance_bucket) + default: return row[name] + } +} + +function formatProductCell (row, name) { + switch (name) { + case 'stock_qty': + case 'sales_qty_90d': + case 'sales_qty_180d': + case 'market_count_90d': + case 'customer_count_90d': + return formatNumber(row[name], 0) + case 'stock_days_90d': + case 'stock_days_180d': + case 'sales_index_90d': + case 'performance_score': + return formatNumber(row[name], name === 'performance_score' ? 1 : 2) + case 'sales_usd_90d': + case 'sales_usd_180d': + case 'avg_price_usd_90d': + case 'avg_price_usd_180d': + case 'base_price_usd': + case 'cost_price_usd': + case 'unit_profit_base_90d': + case 'unit_profit_cost_90d': + case 'unit_profit_base_180d': + case 'unit_profit_cost_180d': + return formatMoney(row[name], 'USD') + case 'gross_margin_90d': + case 'gross_margin_180d': + return formatPercent(row[name]) + case 'urun_ilk_grubu': + return row.urun_ilk_grubu || row.yas_grubu || '' + case 'urun_ana_grubu': + return row.urun_ana_grubu || row.seri || '' + default: + return rawProductCellValue(row, name) || '' + } +} + function normalizeRow (row) { const product = String(row?.product_code || '').trim() const color = String(row?.color_code || '').trim() @@ -595,6 +923,41 @@ async function openProductImageDialog (row) { imageDialog.value = true } +async function openPerformanceDialog (row) { + performanceDialogRow.value = row + performanceDialogTitle.value = String(row?.product_code || '-') + performanceDialogSubtitle.value = [ + row?.item_description, + row?.color_code ? `Renk: ${row.color_code}` : '', + row?.yaka_kodu ? `Yaka: ${row.yaka_kodu}` : '', + row?.market_key ? `Piyasa: ${row.market_key}` : '' + ].filter(Boolean).join(' | ') + performanceDialog.value = true + detailLoading.value = true + detailSalesRows.value = [] + detailStockSizes.value = [] + try { + const params = { + product_code: row?.product_code || '', + color_code: row?.color_code || '', + yaka_kodu: row?.yaka_kodu || '' + } + const [salesResp, stockResp] = await Promise.all([ + api.get('/pricing/product-performance/sales-details', { params: { ...params, limit: 200 }, timeout: 60000 }), + api.get('/pricing/product-performance/stock-sizes', { params, timeout: 60000 }) + ]) + detailSalesRows.value = (Array.isArray(salesResp?.data) ? salesResp.data : []).map((item, idx) => ({ + ...item, + detail_key: `${item.sales_date}|${item.ref_number}|${item.customer_code}|${idx}` + })) + detailStockSizes.value = Array.isArray(stockResp?.data) ? stockResp.data : [] + } catch (err) { + Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün detay analizi alınamadı' }) + } finally { + detailLoading.value = false + } +} + async function reload () { loading.value = true try { @@ -608,7 +971,9 @@ async function reload () { product_code: filters.product_code || undefined, kategori: filters.kategori || undefined, seri: filters.seri || undefined, - bucket: filters.bucket || undefined + bucket: filters.bucket || undefined, + sort_by: pagination.value.sortBy || undefined, + descending: pagination.value.descending ? 'true' : 'false' }, timeout: 60000 }), @@ -709,6 +1074,35 @@ onMounted(reload) background: #fff; } +.performance-table :deep(thead tr:nth-child(2) th) { + top: 40px; + z-index: 2; +} + +.table-filter-row :deep(.q-field__control) { + min-height: 28px; + height: 28px; +} + +.header-filter { + min-width: 96px; +} + +.group-row td { + background: #eef2f6; + font-weight: 600; +} + +.group-title { + margin-left: 4px; +} + +.group-meta { + margin-left: 12px; + color: #5f6b7a; + font-weight: 500; +} + .product-image-cell { width: 72px; min-width: 72px;