ui: add B2B olmayan stok (orphans) page
This commit is contained in:
+10
@@ -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",
|
||||||
|
|||||||
@@ -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"`
|
||||||
@@ -54,6 +66,8 @@ type ProductPerformanceSummary struct {
|
|||||||
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"`
|
||||||
|
MarketCount90 int `json:"market_count_90d"`
|
||||||
|
CustomerCount90 int `json:"customer_count_90d"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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} }
|
||||||
|
|
||||||
@@ -85,14 +85,63 @@
|
|||||||
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">
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.label }}
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
<q-tr class="table-filter-row">
|
||||||
|
<q-th v-for="col in props.cols" :key="`filter-${col.name}`">
|
||||||
|
<q-input
|
||||||
|
v-if="isColumnFilterable(col.name)"
|
||||||
|
v-model="columnFilters[col.name]"
|
||||||
|
dense
|
||||||
|
borderless
|
||||||
|
clearable
|
||||||
|
debounce="250"
|
||||||
|
class="header-filter"
|
||||||
|
placeholder="Filtre"
|
||||||
|
/>
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr v-if="props.row.__group" :props="props" class="group-row">
|
||||||
|
<q-td :colspan="columns.length" :style="{ paddingLeft: `${8 + props.row.level * 18}px` }">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
size="sm"
|
||||||
|
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
|
||||||
|
@click.stop="toggleGroup(props.row.key)"
|
||||||
|
/>
|
||||||
|
<span class="group-title">{{ props.row.label }}</span>
|
||||||
|
<span class="group-meta">
|
||||||
|
{{ formatNumber(props.row.count, 0) }} satır ·
|
||||||
|
Stok {{ formatNumber(props.row.stock_qty, 0) }} ·
|
||||||
|
90G {{ formatNumber(props.row.sales_qty_90d, 0) }} ·
|
||||||
|
180G {{ formatNumber(props.row.sales_qty_180d, 0) }} ·
|
||||||
|
Piyasa {{ formatNumber(props.row.market_count_90d, 0) }} ·
|
||||||
|
Müşteri {{ formatNumber(props.row.customer_count_90d, 0) }} ·
|
||||||
|
Ort.Satış {{ formatMoney(props.row.avg_price_usd_90d, 'USD') }} ·
|
||||||
|
Taban {{ formatMoney(props.row.avg_base_price_usd, 'USD') }} ·
|
||||||
|
Çıplak {{ formatMoney(props.row.avg_cost_price_usd, 'USD') }} ·
|
||||||
|
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)">
|
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
||||||
<q-img
|
<q-img
|
||||||
v-if="getCachedProductImageUrl(props.row)"
|
v-if="getCachedProductImageUrl(props.row)"
|
||||||
@@ -106,27 +155,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<q-tooltip>Foto</q-tooltip>
|
<q-tooltip>Foto</q-tooltip>
|
||||||
</button>
|
</button>
|
||||||
</q-td>
|
|
||||||
</template>
|
</template>
|
||||||
|
<q-badge v-else-if="col.name === 'performance_bucket'" :color="bucketColor(props.row.performance_bucket)">
|
||||||
<template #body-cell-performance_bucket="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-badge :color="bucketColor(props.row.performance_bucket)">
|
|
||||||
{{ bucketLabel(props.row.performance_bucket) }}
|
{{ bucketLabel(props.row.performance_bucket) }}
|
||||||
</q-badge>
|
</q-badge>
|
||||||
|
<span v-else>{{ formatProductCell(props.row, col.name) }}</span>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</q-tr>
|
||||||
|
|
||||||
<template #body-cell-gross_margin_90d="props">
|
|
||||||
<q-td :props="props" class="text-right">
|
|
||||||
{{ formatPercent(props.row.gross_margin_90d) }}
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body-cell-sales_usd_90d="props">
|
|
||||||
<q-td :props="props" class="text-right">
|
|
||||||
{{ formatMoney(props.row.sales_usd_90d, 'USD') }}
|
|
||||||
</q-td>
|
|
||||||
</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;
|
||||||
|
|||||||
Reference in New Issue
Block a user