ui: add B2B olmayan stok (orphans) page

This commit is contained in:
M_Kececi
2026-07-02 16:08:27 +03:00
parent cbb853d433
commit 1bf51b9e82
10 changed files with 1126 additions and 73 deletions
+10
View File
@@ -954,6 +954,16 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "view", "pricing", "view",
wrapV3(routes.GetProductPerformanceCustomersHandler(pgDB)), 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, bindV3(r, pgDB,
"/api/pricing/product-performance/refresh", "POST", "/api/pricing/product-performance/refresh", "POST",
"pricing", "update", "pricing", "update",
+44 -12
View File
@@ -17,19 +17,31 @@ type ProductPerformanceRow struct {
StockQty float64 `json:"stock_qty"` StockQty float64 `json:"stock_qty"`
SalesQty30 float64 `json:"sales_qty_30d"` SalesQty30 float64 `json:"sales_qty_30d"`
SalesQty90 float64 `json:"sales_qty_90d"` SalesQty90 float64 `json:"sales_qty_90d"`
SalesQty180 float64 `json:"sales_qty_180d"`
SalesQty365 float64 `json:"sales_qty_365d"` SalesQty365 float64 `json:"sales_qty_365d"`
SalesQty730 float64 `json:"sales_qty_730d"` SalesQty730 float64 `json:"sales_qty_730d"`
SalesUSD30 float64 `json:"sales_usd_30d"` SalesUSD30 float64 `json:"sales_usd_30d"`
SalesUSD90 float64 `json:"sales_usd_90d"` SalesUSD90 float64 `json:"sales_usd_90d"`
SalesUSD180 float64 `json:"sales_usd_180d"`
SalesUSD365 float64 `json:"sales_usd_365d"` SalesUSD365 float64 `json:"sales_usd_365d"`
AvgDailySales90 float64 `json:"avg_daily_sales_90d"` AvgDailySales90 float64 `json:"avg_daily_sales_90d"`
AvgDailySales180 float64 `json:"avg_daily_sales_180d"`
StockDays90 float64 `json:"stock_days_90d"` StockDays90 float64 `json:"stock_days_90d"`
StockDays180 float64 `json:"stock_days_180d"`
AvgPriceUSD90 float64 `json:"avg_price_usd_90d"` AvgPriceUSD90 float64 `json:"avg_price_usd_90d"`
AvgPriceUSD180 float64 `json:"avg_price_usd_180d"`
CostPriceUSD float64 `json:"cost_price_usd"` CostPriceUSD float64 `json:"cost_price_usd"`
BasePriceUSD float64 `json:"base_price_usd"` BasePriceUSD float64 `json:"base_price_usd"`
BasePriceTRY float64 `json:"base_price_try"` BasePriceTRY float64 `json:"base_price_try"`
GrossProfitUSD90 float64 `json:"gross_profit_usd_90d"` GrossProfitUSD90 float64 `json:"gross_profit_usd_90d"`
GrossProfitUSD180 float64 `json:"gross_profit_usd_180d"`
GrossMargin90 float64 `json:"gross_margin_90d"` 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"` CustomerCount90 int `json:"customer_count_90d"`
SalesIndex90 float64 `json:"sales_index_90d"` SalesIndex90 float64 `json:"sales_index_90d"`
PriceIndex90 float64 `json:"price_index_90d"` PriceIndex90 float64 `json:"price_index_90d"`
@@ -43,18 +55,20 @@ type ProductPerformanceRow struct {
} }
type ProductPerformanceSummary struct { type ProductPerformanceSummary struct {
KpiDate string `json:"kpi_date"` KpiDate string `json:"kpi_date"`
TotalRows int `json:"total_rows"` TotalRows int `json:"total_rows"`
StarCount int `json:"star_count"` StarCount int `json:"star_count"`
StockRisk int `json:"stock_risk"` StockRisk int `json:"stock_risk"`
NoStockDemand int `json:"no_stock_demand"` NoStockDemand int `json:"no_stock_demand"`
TotalStock float64 `json:"total_stock"` TotalStock float64 `json:"total_stock"`
StockCostUSD float64 `json:"stock_cost_usd"` StockCostUSD float64 `json:"stock_cost_usd"`
RiskCostUSD float64 `json:"risk_cost_usd"` RiskCostUSD float64 `json:"risk_cost_usd"`
SalesQty90 float64 `json:"sales_qty_90d"` SalesQty90 float64 `json:"sales_qty_90d"`
SalesUSD90 float64 `json:"sales_usd_90d"` SalesUSD90 float64 `json:"sales_usd_90d"`
GrossProfit90 float64 `json:"gross_profit_usd_90d"` GrossProfit90 float64 `json:"gross_profit_usd_90d"`
UpdatedAt string `json:"updated_at"` MarketCount90 int `json:"market_count_90d"`
CustomerCount90 int `json:"customer_count_90d"`
UpdatedAt string `json:"updated_at"`
} }
type ProductPerformanceMarketRow struct { type ProductPerformanceMarketRow struct {
@@ -111,3 +125,21 @@ type ProductPerformanceCustomerRow struct {
SalesUSD365 float64 `json:"sales_usd_365d"` SalesUSD365 float64 `json:"sales_usd_365d"`
LastSaleDate string `json:"last_sale_date"` 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"`
}
+177 -12
View File
@@ -18,6 +18,8 @@ type ProductPerformanceFilters struct {
Kategori string Kategori string
Seri string Seri string
Bucket string Bucket string
SortBy string
Descending bool
Limit int Limit int
Page 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, stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_qty_30d 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_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_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_qty_730d 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_30d NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_usd_90d 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, sales_usd_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
avg_daily_sales_90d NUMERIC(18,6) 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_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_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, cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
base_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, base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0,
gross_profit_usd_90d NUMERIC(18,4) 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_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, customer_count_90d INTEGER NOT NULL DEFAULT 0,
sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
price_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_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_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 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 { for _, stmt := range stmts {
if _, err := pg.Exec(stmt); err != nil { 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 return nil, 0, err
} }
args = append(args, limit, (page-1)*limit) args = append(args, limit, (page-1)*limit)
orderBy := productPerformanceOrderBy(f.SortBy, f.Descending)
rows, err := pg.QueryContext(ctx, ` rows, err := pg.QueryContext(ctx, `
SELECT SELECT
to_char(kpi_date,'YYYY-MM-DD'), product_code, color_code, yaka_kodu, item_description, 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, 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_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d,
sales_usd_30d, sales_usd_90d, sales_usd_365d, avg_daily_sales_90d, sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, avg_daily_sales_90d, avg_daily_sales_180d,
stock_days_90d, avg_price_usd_90d, cost_price_usd, base_price_usd, base_price_try, 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_margin_90d, customer_count_90d, sales_index_90d, 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, price_index_90d, margin_index_90d, performance_score, performance_bucket,
recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number, recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number,
to_char(updated_at,'YYYY-MM-DD HH24:MI:SS') to_char(updated_at,'YYYY-MM-DD HH24:MI:SS')
FROM mk_product_performance_kpi_daily FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)`+where+` 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...) LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -656,10 +684,11 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
if err := rows.Scan( if err := rows.Scan(
&r.KpiDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &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.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.SalesQty30, &r.SalesQty90, &r.SalesQty180, &r.SalesQty365, &r.SalesQty730,
&r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD365, &r.AvgDailySales90, &r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.AvgDailySales90, &r.AvgDailySales180,
&r.StockDays90, &r.AvgPriceUSD90, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY, &r.StockDays90, &r.StockDays180, &r.AvgPriceUSD90, &r.AvgPriceUSD180, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY,
&r.GrossProfitUSD90, &r.GrossMargin90, &r.CustomerCount90, &r.SalesIndex90, &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.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket,
&r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt, &r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt,
); err != nil { ); err != nil {
@@ -676,6 +705,22 @@ func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.Produ
} }
var s models.ProductPerformanceSummary var s models.ProductPerformanceSummary
err := pg.QueryRowContext(ctx, ` 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 SELECT
COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''), COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''),
COUNT(*), COUNT(*),
@@ -688,10 +733,12 @@ SELECT
COALESCE(SUM(sales_qty_90d),0), COALESCE(SUM(sales_qty_90d),0),
COALESCE(SUM(sales_usd_90d),0), COALESCE(SUM(sales_usd_90d),0),
COALESCE(SUM(gross_profit_usd_90d),0), COALESCE(SUM(gross_profit_usd_90d),0),
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'),'') COALESCE(to_char(MAX(updated_at),'YYYY-MM-DD HH24:MI:SS'),'')
FROM mk_product_performance_kpi_daily FROM KPI
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) 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.UpdatedAt) `).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.MarketCount90, &s.CustomerCount90, &s.UpdatedAt)
return s, err return s, err
} }
@@ -879,6 +926,76 @@ LIMIT $1
return out, rows.Err() 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) { func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) {
parts := make([]string, 0, 6) parts := make([]string, 0, 6)
args := make([]any, 0, 6) args := make([]any, 0, 6)
@@ -911,6 +1028,54 @@ func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) {
return strings.Join(parts, ""), args 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 { func dateOnly(t time.Time) time.Time {
y, m, d := t.Date() y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
+44 -14
View File
@@ -422,12 +422,14 @@ SalesAgg AS (
MAX(urun_alt_grubu) AS urun_alt_grubu, 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 '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 '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 '364 days') AS sales_qty_365d,
SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '729 days') AS sales_qty_730d, 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 '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 '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(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(sales_date) AS last_sale_date,
MAX(last_ref_number) AS last_ref_number MAX(last_ref_number) AS last_ref_number
FROM mk_product_performance_sales_daily FROM mk_product_performance_sales_daily
@@ -439,8 +441,8 @@ Scope AS (
SELECT SELECT
product_code, color_code, yaka_kodu, market_key, 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, 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_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d,
sales_usd_30d, sales_usd_90d, sales_usd_365d, sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d,
customer_count_90d, last_sale_date, last_ref_number customer_count_90d, last_sale_date, last_ref_number
FROM SalesAgg FROM SalesAgg
UNION ALL UNION ALL
@@ -459,10 +461,12 @@ Scope AS (
'' AS urun_alt_grubu, '' AS urun_alt_grubu,
0 AS sales_qty_30d, 0 AS sales_qty_30d,
0 AS sales_qty_90d, 0 AS sales_qty_90d,
0 AS sales_qty_180d,
0 AS sales_qty_365d, 0 AS sales_qty_365d,
0 AS sales_qty_730d, 0 AS sales_qty_730d,
0 AS sales_usd_30d, 0 AS sales_usd_30d,
0 AS sales_usd_90d, 0 AS sales_usd_90d,
0 AS sales_usd_180d,
0 AS sales_usd_365d, 0 AS sales_usd_365d,
0 AS customer_count_90d, 0 AS customer_count_90d,
NULL::date AS last_sale_date, 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_usd, 0) AS base_price_usd,
COALESCE(pd.base_price_try, 0) AS base_price_try, 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_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_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_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 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) 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 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 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 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 ( Market AS (
SELECT SELECT
market_key, market_key,
@@ -506,20 +530,24 @@ Market AS (
Scored AS ( Scored AS (
SELECT SELECT
b.*, 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_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_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_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 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 FROM Base b
LEFT JOIN Market m ON m.market_key=b.market_key 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 ( INSERT INTO mk_product_performance_kpi_daily (
kpi_date, product_code, color_code, yaka_kodu, item_description, 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, 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_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d,
sales_usd_30d, sales_usd_90d, sales_usd_365d, avg_daily_sales_90d, sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, avg_daily_sales_90d, avg_daily_sales_180d,
stock_days_90d, avg_price_usd_90d, cost_price_usd, base_price_usd, base_price_try, 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_margin_90d, customer_count_90d, 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, sales_index_90d, price_index_90d, margin_index_90d, performance_score,
performance_bucket, recommendation, last_sale_date, last_ref_number, updated_at performance_bucket, recommendation, last_sale_date, last_ref_number, updated_at
) )
@@ -527,16 +555,18 @@ SELECT
$1::date, $1::date,
product_code, color_code, yaka_kodu, item_description, 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, 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_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_365d, 0), COALESCE(avg_daily_sales_90d, 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(avg_price_usd_90d, 0), COALESCE(cost_price_usd, 0), COALESCE(base_price_usd, 0), COALESCE(base_price_try, 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_margin_90d, 0), COALESCE(customer_count_90d, 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), COALESCE(sales_index_90d, 0), COALESCE(price_index_90d, 0), COALESCE(margin_index_90d, 0),
ROUND( 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(25, GREATEST(COALESCE(gross_margin_90d, 0), 0) * 50)
+ LEAST(20, COALESCE(customer_count_90d, 0) * 2) + 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, , 4) AS performance_score,
CASE CASE
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'YILDIZ_URUN' WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'YILDIZ_URUN'
+50
View File
@@ -26,6 +26,8 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc {
Kategori: strings.TrimSpace(r.URL.Query().Get("kategori")), Kategori: strings.TrimSpace(r.URL.Query().Get("kategori")),
Seri: strings.TrimSpace(r.URL.Query().Get("seri")), Seri: strings.TrimSpace(r.URL.Query().Get("seri")),
Bucket: strings.TrimSpace(r.URL.Query().Get("bucket")), 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), Limit: intQuery(r, "limit", 100),
Page: intQuery(r, "page", 1), 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 { func GetProductPerformanceSummaryHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") 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 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 { func parseDateOrZero(raw string) time.Time {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
+75
View File
@@ -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 <name>" 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
}
}
+158
View File
@@ -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 <name>" 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)
})
})
+116
View File
@@ -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 <name>" 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()
})
})
}
@@ -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 <name>" 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} }
+429 -35
View File
@@ -85,48 +85,83 @@
bordered bordered
row-key="row_key" row-key="row_key"
class="performance-table bg-white" class="performance-table bg-white"
:rows="rows" :rows="productTableRows"
:columns="columns" :columns="columns"
:loading="loading" :loading="loading"
:pagination="pagination" :pagination="pagination"
@request="onRequest" @request="onRequest"
> >
<template #body-cell-image="props"> <template #header="props">
<q-td :props="props" class="product-image-cell"> <q-tr :props="props">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)"> <q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-img {{ col.label }}
v-if="getCachedProductImageUrl(props.row)" </q-th>
:src="getCachedProductImageUrl(props.row)" </q-tr>
fit="contain" <q-tr class="table-filter-row">
class="product-thumb" <q-th v-for="col in props.cols" :key="`filter-${col.name}`">
no-spinner <q-input
v-if="isColumnFilterable(col.name)"
v-model="columnFilters[col.name]"
dense
borderless
clearable
debounce="250"
class="header-filter"
placeholder="Filtre"
/> />
<div v-else class="product-thumb-placeholder"> </q-th>
<q-icon name="image" size="22px" /> </q-tr>
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</template> </template>
<template #body-cell-performance_bucket="props"> <template #body="props">
<q-td :props="props"> <q-tr v-if="props.row.__group" :props="props" class="group-row">
<q-badge :color="bucketColor(props.row.performance_bucket)"> <q-td :colspan="columns.length" :style="{ paddingLeft: `${8 + props.row.level * 18}px` }">
{{ bucketLabel(props.row.performance_bucket) }} <q-btn
</q-badge> flat
</q-td> dense
</template> round
size="sm"
<template #body-cell-gross_margin_90d="props"> :icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
<q-td :props="props" class="text-right"> @click.stop="toggleGroup(props.row.key)"
{{ formatPercent(props.row.gross_margin_90d) }} />
</q-td> <span class="group-title">{{ props.row.label }}</span>
</template> <span class="group-meta">
{{ formatNumber(props.row.count, 0) }} satır ·
<template #body-cell-sales_usd_90d="props"> Stok {{ formatNumber(props.row.stock_qty, 0) }} ·
<q-td :props="props" class="text-right"> 90G {{ formatNumber(props.row.sales_qty_90d, 0) }} ·
{{ formatMoney(props.row.sales_usd_90d, 'USD') }} 180G {{ formatNumber(props.row.sales_qty_180d, 0) }} ·
</q-td> Piyasa {{ formatNumber(props.row.market_count_90d, 0) }} ·
Müşteri {{ formatNumber(props.row.customer_count_90d, 0) }} ·
Ort.Satış {{ formatMoney(props.row.avg_price_usd_90d, 'USD') }} ·
Taban {{ formatMoney(props.row.avg_base_price_usd, 'USD') }} ·
Çıplak {{ formatMoney(props.row.avg_cost_price_usd, 'USD') }} ·
Marj {{ formatPercent(props.row.avg_gross_margin_90d) }}
</span>
</q-td>
</q-tr>
<q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<template v-if="col.name === 'image'">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<q-img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
fit="contain"
class="product-thumb"
no-spinner
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</template>
<q-badge v-else-if="col.name === 'performance_bucket'" :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
<span v-else>{{ formatProductCell(props.row, col.name) }}</span>
</q-td>
</q-tr>
</template> </template>
</q-table> </q-table>
@@ -307,6 +342,60 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="performanceDialog" maximized>
<q-card class="product-performance-dialog">
<q-card-section class="row items-center q-pb-sm">
<div>
<div class="text-h6">{{ performanceDialogTitle }}</div>
<div class="text-caption text-grey-7">{{ performanceDialogSubtitle }}</div>
</div>
<q-space />
<q-btn v-close-popup flat round dense icon="close" />
</q-card-section>
<q-separator />
<q-card-section class="q-pa-sm">
<div class="row q-col-gutter-sm q-mb-sm">
<div v-for="card in performanceCards" :key="card.key" class="col-6 col-md-2">
<q-card flat bordered class="metric-card">
<q-card-section>
<div class="text-caption text-grey-7">{{ card.label }}</div>
<div class="metric-value">{{ card.value }}</div>
</q-card-section>
</q-card>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-table
flat
bordered
dense
title="Beden Stok"
row-key="size_code"
:rows="detailStockSizes"
:columns="detailStockColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 50 }"
/>
</div>
<div class="col-12 col-md-8">
<q-table
flat
bordered
dense
title="Satış / Müşteri Özeti"
row-key="detail_key"
:rows="detailSalesRows"
:columns="detailSalesColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 50 }"
/>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</q-page> </q-page>
</template> </template>
@@ -336,6 +425,13 @@ const imageDialogUrls = ref([])
const imageDialogTitle = ref('') const imageDialogTitle = ref('')
const imageDialogSubtitle = ref('') const imageDialogSubtitle = ref('')
const imageSlide = ref(0) 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({ const filters = reactive({
q: '', q: '',
@@ -344,6 +440,8 @@ const filters = reactive({
seri: '', seri: '',
bucket: '' bucket: ''
}) })
const columnFilters = reactive({})
const expandedGroups = ref({})
const pagination = ref({ const pagination = ref({
page: 1, page: 1,
@@ -368,6 +466,23 @@ const customerBreakdownOptions = [
{ label: 'Piyasa > Ülke > Müşteri', value: 'market_country_customer' } { 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 = [ const columns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' }, { name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, { 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: '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_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: '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: '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_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_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_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_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_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: '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_score', label: 'Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' }, { 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: '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: '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: '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: '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') } { 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))) .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) { function normalizeRow (row) {
const product = String(row?.product_code || '').trim() const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim() const color = String(row?.color_code || '').trim()
@@ -595,6 +923,41 @@ async function openProductImageDialog (row) {
imageDialog.value = true 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 () { async function reload () {
loading.value = true loading.value = true
try { try {
@@ -608,7 +971,9 @@ async function reload () {
product_code: filters.product_code || undefined, product_code: filters.product_code || undefined,
kategori: filters.kategori || undefined, kategori: filters.kategori || undefined,
seri: filters.seri || 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 timeout: 60000
}), }),
@@ -709,6 +1074,35 @@ onMounted(reload)
background: #fff; 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 { .product-image-cell {
width: 72px; width: 72px;
min-width: 72px; min-width: 72px;