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",
|
||||
wrapV3(routes.GetProductPerformanceCustomersHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/sales-details", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceSalesDetailsHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/stock-sizes", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceStockSizesHandler()),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/refresh", "POST",
|
||||
"pricing", "update",
|
||||
|
||||
@@ -17,19 +17,31 @@ type ProductPerformanceRow struct {
|
||||
StockQty float64 `json:"stock_qty"`
|
||||
SalesQty30 float64 `json:"sales_qty_30d"`
|
||||
SalesQty90 float64 `json:"sales_qty_90d"`
|
||||
SalesQty180 float64 `json:"sales_qty_180d"`
|
||||
SalesQty365 float64 `json:"sales_qty_365d"`
|
||||
SalesQty730 float64 `json:"sales_qty_730d"`
|
||||
SalesUSD30 float64 `json:"sales_usd_30d"`
|
||||
SalesUSD90 float64 `json:"sales_usd_90d"`
|
||||
SalesUSD180 float64 `json:"sales_usd_180d"`
|
||||
SalesUSD365 float64 `json:"sales_usd_365d"`
|
||||
AvgDailySales90 float64 `json:"avg_daily_sales_90d"`
|
||||
AvgDailySales180 float64 `json:"avg_daily_sales_180d"`
|
||||
StockDays90 float64 `json:"stock_days_90d"`
|
||||
StockDays180 float64 `json:"stock_days_180d"`
|
||||
AvgPriceUSD90 float64 `json:"avg_price_usd_90d"`
|
||||
AvgPriceUSD180 float64 `json:"avg_price_usd_180d"`
|
||||
CostPriceUSD float64 `json:"cost_price_usd"`
|
||||
BasePriceUSD float64 `json:"base_price_usd"`
|
||||
BasePriceTRY float64 `json:"base_price_try"`
|
||||
GrossProfitUSD90 float64 `json:"gross_profit_usd_90d"`
|
||||
GrossProfitUSD180 float64 `json:"gross_profit_usd_180d"`
|
||||
GrossMargin90 float64 `json:"gross_margin_90d"`
|
||||
GrossMargin180 float64 `json:"gross_margin_180d"`
|
||||
UnitProfitCost90 float64 `json:"unit_profit_cost_90d"`
|
||||
UnitProfitCost180 float64 `json:"unit_profit_cost_180d"`
|
||||
UnitProfitBase90 float64 `json:"unit_profit_base_90d"`
|
||||
UnitProfitBase180 float64 `json:"unit_profit_base_180d"`
|
||||
MarketCount90 int `json:"market_count_90d"`
|
||||
CustomerCount90 int `json:"customer_count_90d"`
|
||||
SalesIndex90 float64 `json:"sales_index_90d"`
|
||||
PriceIndex90 float64 `json:"price_index_90d"`
|
||||
@@ -54,6 +66,8 @@ type ProductPerformanceSummary struct {
|
||||
SalesQty90 float64 `json:"sales_qty_90d"`
|
||||
SalesUSD90 float64 `json:"sales_usd_90d"`
|
||||
GrossProfit90 float64 `json:"gross_profit_usd_90d"`
|
||||
MarketCount90 int `json:"market_count_90d"`
|
||||
CustomerCount90 int `json:"customer_count_90d"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -111,3 +125,21 @@ type ProductPerformanceCustomerRow struct {
|
||||
SalesUSD365 float64 `json:"sales_usd_365d"`
|
||||
LastSaleDate string `json:"last_sale_date"`
|
||||
}
|
||||
|
||||
type ProductPerformanceSalesDetailRow struct {
|
||||
SalesDate string `json:"sales_date"`
|
||||
RefNumber string `json:"ref_number"`
|
||||
MarketKey string `json:"market_key"`
|
||||
Country string `json:"country"`
|
||||
CustomerSegment string `json:"customer_segment"`
|
||||
CustomerCode string `json:"customer_code"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
SalesQty float64 `json:"sales_qty"`
|
||||
SalesUSD float64 `json:"sales_usd"`
|
||||
AvgPriceUSD float64 `json:"avg_price_usd"`
|
||||
}
|
||||
|
||||
type ProductPerformanceStockSizeRow struct {
|
||||
SizeCode string `json:"size_code"`
|
||||
StockQty float64 `json:"stock_qty"`
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ type ProductPerformanceFilters struct {
|
||||
Kategori string
|
||||
Seri string
|
||||
Bucket string
|
||||
SortBy string
|
||||
Descending bool
|
||||
Limit int
|
||||
Page int
|
||||
}
|
||||
@@ -149,19 +151,31 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
|
||||
stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
sales_qty_30d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
sales_qty_90d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
sales_qty_180d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
sales_qty_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
sales_qty_730d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
sales_usd_30d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
sales_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
sales_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
sales_usd_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
stock_days_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
avg_price_usd_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
avg_price_usd_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
gross_profit_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
gross_profit_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||
gross_margin_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
gross_margin_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
unit_profit_cost_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
unit_profit_cost_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
market_count_90d INTEGER NOT NULL DEFAULT 0,
|
||||
customer_count_90d INTEGER NOT NULL DEFAULT 0,
|
||||
sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
price_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
@@ -181,6 +195,18 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_qty_180d NUMERIC(18,4) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_price_usd_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS gross_profit_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS gross_margin_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_cost_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_cost_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_90d INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := pg.Exec(stmt); err != nil {
|
||||
@@ -631,20 +657,22 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc
|
||||
return nil, 0, err
|
||||
}
|
||||
args = append(args, limit, (page-1)*limit)
|
||||
orderBy := productPerformanceOrderBy(f.SortBy, f.Descending)
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT
|
||||
to_char(kpi_date,'YYYY-MM-DD'), product_code, color_code, yaka_kodu, item_description,
|
||||
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty,
|
||||
sales_qty_30d, sales_qty_90d, sales_qty_365d, sales_qty_730d,
|
||||
sales_usd_30d, sales_usd_90d, sales_usd_365d, avg_daily_sales_90d,
|
||||
stock_days_90d, avg_price_usd_90d, cost_price_usd, base_price_usd, base_price_try,
|
||||
gross_profit_usd_90d, gross_margin_90d, customer_count_90d, sales_index_90d,
|
||||
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d,
|
||||
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, avg_daily_sales_90d, avg_daily_sales_180d,
|
||||
stock_days_90d, stock_days_180d, avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try,
|
||||
gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d,
|
||||
unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d, market_count_90d, customer_count_90d, sales_index_90d,
|
||||
price_index_90d, margin_index_90d, performance_score, performance_bucket,
|
||||
recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number,
|
||||
to_char(updated_at,'YYYY-MM-DD HH24:MI:SS')
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)`+where+`
|
||||
ORDER BY performance_score DESC, sales_qty_90d DESC, product_code
|
||||
ORDER BY `+orderBy+`
|
||||
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -656,10 +684,11 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
||||
if err := rows.Scan(
|
||||
&r.KpiDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
|
||||
&r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.StockQty,
|
||||
&r.SalesQty30, &r.SalesQty90, &r.SalesQty365, &r.SalesQty730,
|
||||
&r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD365, &r.AvgDailySales90,
|
||||
&r.StockDays90, &r.AvgPriceUSD90, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY,
|
||||
&r.GrossProfitUSD90, &r.GrossMargin90, &r.CustomerCount90, &r.SalesIndex90,
|
||||
&r.SalesQty30, &r.SalesQty90, &r.SalesQty180, &r.SalesQty365, &r.SalesQty730,
|
||||
&r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.AvgDailySales90, &r.AvgDailySales180,
|
||||
&r.StockDays90, &r.StockDays180, &r.AvgPriceUSD90, &r.AvgPriceUSD180, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY,
|
||||
&r.GrossProfitUSD90, &r.GrossProfitUSD180, &r.GrossMargin90, &r.GrossMargin180,
|
||||
&r.UnitProfitCost90, &r.UnitProfitCost180, &r.UnitProfitBase90, &r.UnitProfitBase180, &r.MarketCount90, &r.CustomerCount90, &r.SalesIndex90,
|
||||
&r.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket,
|
||||
&r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -676,6 +705,22 @@ func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.Produ
|
||||
}
|
||||
var s models.ProductPerformanceSummary
|
||||
err := pg.QueryRowContext(ctx, `
|
||||
WITH Latest AS (
|
||||
SELECT MAX(kpi_date) AS kpi_date
|
||||
FROM mk_product_performance_kpi_daily
|
||||
),
|
||||
KPI AS (
|
||||
SELECT *
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT kpi_date FROM Latest)
|
||||
),
|
||||
Sales90 AS (
|
||||
SELECT
|
||||
COUNT(DISTINCT market_key) AS market_count_90d,
|
||||
COUNT(DISTINCT NULLIF(customer_code, '-')) AS customer_count_90d
|
||||
FROM mk_product_performance_sales_daily
|
||||
WHERE sales_date BETWEEN (SELECT kpi_date FROM Latest) - INTERVAL '89 days' AND (SELECT kpi_date FROM Latest)
|
||||
)
|
||||
SELECT
|
||||
COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''),
|
||||
COUNT(*),
|
||||
@@ -688,10 +733,12 @@ SELECT
|
||||
COALESCE(SUM(sales_qty_90d),0),
|
||||
COALESCE(SUM(sales_usd_90d),0),
|
||||
COALESCE(SUM(gross_profit_usd_90d),0),
|
||||
COALESCE(MAX(Sales90.market_count_90d),0)::integer,
|
||||
COALESCE(MAX(Sales90.customer_count_90d),0)::integer,
|
||||
COALESCE(to_char(MAX(updated_at),'YYYY-MM-DD HH24:MI:SS'),'')
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
||||
`).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.UpdatedAt)
|
||||
FROM KPI
|
||||
CROSS JOIN Sales90
|
||||
`).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.MarketCount90, &s.CustomerCount90, &s.UpdatedAt)
|
||||
return s, err
|
||||
}
|
||||
|
||||
@@ -879,6 +926,76 @@ LIMIT $1
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT
|
||||
to_char(sales_date,'YYYY-MM-DD') AS sales_date,
|
||||
last_ref_number,
|
||||
market_key,
|
||||
customer_country,
|
||||
customer_segment,
|
||||
customer_code,
|
||||
customer_name,
|
||||
sales_qty,
|
||||
sales_usd,
|
||||
avg_price_usd
|
||||
FROM mk_product_performance_sales_daily
|
||||
WHERE product_code=$1
|
||||
AND color_code=$2
|
||||
AND yaka_kodu=$3
|
||||
ORDER BY sales_date DESC, sales_usd DESC
|
||||
LIMIT $4
|
||||
`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.ProductPerformanceSalesDetailRow, 0, limit)
|
||||
for rows.Next() {
|
||||
var r models.ProductPerformanceSalesDetailRow
|
||||
if err := rows.Scan(&r.SalesDate, &r.RefNumber, &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &r.SalesQty, &r.SalesUSD, &r.AvgPriceUSD); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func ListProductPerformanceStockSizes(ctx context.Context, productCode, colorCode, yakaKodu string) ([]models.ProductPerformanceStockSizeRow, error) {
|
||||
if db.MssqlDB == nil {
|
||||
return nil, fmt.Errorf("mssql db nil")
|
||||
}
|
||||
rows, err := db.MssqlDB.QueryContext(ctx, `
|
||||
SELECT
|
||||
SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))),
|
||||
StockQty = SUM(S.InventoryQty1)
|
||||
FROM StockWithCost S WITH(NOLOCK)
|
||||
WHERE S.ItemTypeCode = 1
|
||||
AND LTRIM(RTRIM(S.ItemCode)) = @p1
|
||||
AND LTRIM(RTRIM(ISNULL(S.ColorCode, ''))) = @p2
|
||||
AND LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))) = @p3
|
||||
GROUP BY LTRIM(RTRIM(ISNULL(S.ItemDim1Code, '')))
|
||||
HAVING SUM(S.InventoryQty1) <> 0
|
||||
ORDER BY LTRIM(RTRIM(ISNULL(S.ItemDim1Code, '')))
|
||||
`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.ProductPerformanceStockSizeRow, 0, 32)
|
||||
for rows.Next() {
|
||||
var r models.ProductPerformanceStockSizeRow
|
||||
if err := rows.Scan(&r.SizeCode, &r.StockQty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) {
|
||||
parts := make([]string, 0, 6)
|
||||
args := make([]any, 0, 6)
|
||||
@@ -911,6 +1028,54 @@ func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) {
|
||||
return strings.Join(parts, ""), args
|
||||
}
|
||||
|
||||
func productPerformanceOrderBy(sortBy string, desc bool) string {
|
||||
allowed := map[string]string{
|
||||
"product_code": "product_code",
|
||||
"color_code": "color_code",
|
||||
"yaka_kodu": "yaka_kodu",
|
||||
"item_description": "item_description",
|
||||
"kategori": "kategori",
|
||||
"urun_ilk_grubu": "urun_ilk_grubu",
|
||||
"askili_yan": "askili_yan",
|
||||
"urun_ana_grubu": "urun_ana_grubu",
|
||||
"urun_alt_grubu": "urun_alt_grubu",
|
||||
"market_key": "market_key",
|
||||
"stock_qty": "stock_qty",
|
||||
"sales_qty_90d": "sales_qty_90d",
|
||||
"sales_qty_180d": "sales_qty_180d",
|
||||
"sales_usd_90d": "sales_usd_90d",
|
||||
"sales_usd_180d": "sales_usd_180d",
|
||||
"stock_days_90d": "stock_days_90d",
|
||||
"stock_days_180d": "stock_days_180d",
|
||||
"avg_price_usd_90d": "avg_price_usd_90d",
|
||||
"avg_price_usd_180d": "avg_price_usd_180d",
|
||||
"base_price_usd": "base_price_usd",
|
||||
"cost_price_usd": "cost_price_usd",
|
||||
"unit_profit_base_90d": "unit_profit_base_90d",
|
||||
"unit_profit_cost_90d": "unit_profit_cost_90d",
|
||||
"unit_profit_base_180d": "unit_profit_base_180d",
|
||||
"unit_profit_cost_180d": "unit_profit_cost_180d",
|
||||
"gross_margin_90d": "gross_margin_90d",
|
||||
"gross_margin_180d": "gross_margin_180d",
|
||||
"market_count_90d": "market_count_90d",
|
||||
"customer_count_90d": "customer_count_90d",
|
||||
"sales_index_90d": "sales_index_90d",
|
||||
"performance_score": "performance_score",
|
||||
"performance_bucket": "performance_bucket",
|
||||
"last_sale_date": "last_sale_date",
|
||||
}
|
||||
col := allowed[strings.TrimSpace(sortBy)]
|
||||
if col == "" {
|
||||
col = "performance_score"
|
||||
desc = true
|
||||
}
|
||||
dir := "ASC"
|
||||
if desc {
|
||||
dir = "DESC"
|
||||
}
|
||||
return col + " " + dir + ", product_code ASC, color_code ASC, yaka_kodu ASC"
|
||||
}
|
||||
|
||||
func dateOnly(t time.Time) time.Time {
|
||||
y, m, d := t.Date()
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
|
||||
|
||||
@@ -422,12 +422,14 @@ SalesAgg AS (
|
||||
MAX(urun_alt_grubu) AS urun_alt_grubu,
|
||||
SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '29 days') AS sales_qty_30d,
|
||||
SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS sales_qty_90d,
|
||||
SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS sales_qty_180d,
|
||||
SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '364 days') AS sales_qty_365d,
|
||||
SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '729 days') AS sales_qty_730d,
|
||||
SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '29 days') AS sales_usd_30d,
|
||||
SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS sales_usd_90d,
|
||||
SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS sales_usd_180d,
|
||||
SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '364 days') AS sales_usd_365d,
|
||||
SUM(customer_count) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS customer_count_90d,
|
||||
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS customer_count_90d,
|
||||
MAX(sales_date) AS last_sale_date,
|
||||
MAX(last_ref_number) AS last_ref_number
|
||||
FROM mk_product_performance_sales_daily
|
||||
@@ -439,8 +441,8 @@ Scope AS (
|
||||
SELECT
|
||||
product_code, color_code, yaka_kodu, market_key,
|
||||
item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu,
|
||||
sales_qty_30d, sales_qty_90d, sales_qty_365d, sales_qty_730d,
|
||||
sales_usd_30d, sales_usd_90d, sales_usd_365d,
|
||||
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d,
|
||||
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d,
|
||||
customer_count_90d, last_sale_date, last_ref_number
|
||||
FROM SalesAgg
|
||||
UNION ALL
|
||||
@@ -459,10 +461,12 @@ Scope AS (
|
||||
'' AS urun_alt_grubu,
|
||||
0 AS sales_qty_30d,
|
||||
0 AS sales_qty_90d,
|
||||
0 AS sales_qty_180d,
|
||||
0 AS sales_qty_365d,
|
||||
0 AS sales_qty_730d,
|
||||
0 AS sales_usd_30d,
|
||||
0 AS sales_usd_90d,
|
||||
0 AS sales_usd_180d,
|
||||
0 AS sales_usd_365d,
|
||||
0 AS customer_count_90d,
|
||||
NULL::date AS last_sale_date,
|
||||
@@ -485,15 +489,35 @@ Base AS (
|
||||
COALESCE(pd.base_price_usd, 0) AS base_price_usd,
|
||||
COALESCE(pd.base_price_try, 0) AS base_price_try,
|
||||
COALESCE(s.sales_qty_90d, 0) / 90.0 AS avg_daily_sales_90d,
|
||||
COALESCE(s.sales_qty_180d, 0) / 180.0 AS avg_daily_sales_180d,
|
||||
CASE WHEN COALESCE(s.sales_qty_90d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_90d, 0) / NULLIF(s.sales_qty_90d, 0) END AS avg_price_usd_90d,
|
||||
CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0) END AS avg_price_usd_180d,
|
||||
COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_90d,
|
||||
COALESCE(s.sales_usd_180d, 0) - (COALESCE(s.sales_qty_180d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_180d,
|
||||
CASE WHEN COALESCE(s.sales_usd_90d, 0) = 0 THEN 0
|
||||
ELSE (COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0))) / NULLIF(s.sales_usd_90d, 0)
|
||||
END AS gross_margin_90d
|
||||
END AS gross_margin_90d,
|
||||
CASE WHEN COALESCE(s.sales_usd_180d, 0) = 0 THEN 0
|
||||
ELSE (COALESCE(s.sales_usd_180d, 0) - (COALESCE(s.sales_qty_180d, 0) * COALESCE(pd.cost_price_usd, 0))) / NULLIF(s.sales_usd_180d, 0)
|
||||
END AS gross_margin_180d,
|
||||
CASE WHEN COALESCE(s.sales_qty_90d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_90d, 0) / NULLIF(s.sales_qty_90d, 0)) - COALESCE(pd.cost_price_usd, 0) END AS unit_profit_cost_90d,
|
||||
CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0)) - COALESCE(pd.cost_price_usd, 0) END AS unit_profit_cost_180d,
|
||||
CASE WHEN COALESCE(s.sales_qty_90d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_90d, 0) / NULLIF(s.sales_qty_90d, 0)) - COALESCE(pd.base_price_usd, 0) END AS unit_profit_base_90d,
|
||||
CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0)) - COALESCE(pd.base_price_usd, 0) END AS unit_profit_base_180d
|
||||
FROM Scope s
|
||||
LEFT JOIN LatestStock ls ON ls.product_code=s.product_code AND ls.color_code=s.color_code AND ls.yaka_kodu=s.yaka_kodu
|
||||
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=s.product_code
|
||||
),
|
||||
Spread AS (
|
||||
SELECT
|
||||
product_code,
|
||||
color_code,
|
||||
yaka_kodu,
|
||||
COUNT(DISTINCT market_key) AS market_count_90d
|
||||
FROM SalesAgg
|
||||
WHERE COALESCE(sales_qty_90d, 0) > 0
|
||||
GROUP BY product_code, color_code, yaka_kodu
|
||||
),
|
||||
Market AS (
|
||||
SELECT
|
||||
market_key,
|
||||
@@ -506,20 +530,24 @@ Market AS (
|
||||
Scored AS (
|
||||
SELECT
|
||||
b.*,
|
||||
COALESCE(sp.market_count_90d, 0) AS market_count_90d,
|
||||
CASE WHEN b.avg_daily_sales_90d = 0 THEN 0 ELSE b.stock_qty / NULLIF(b.avg_daily_sales_90d, 0) END AS stock_days_90d,
|
||||
CASE WHEN b.avg_daily_sales_180d = 0 THEN 0 ELSE b.stock_qty / NULLIF(b.avg_daily_sales_180d, 0) END AS stock_days_180d,
|
||||
CASE WHEN COALESCE(m.market_avg_sales_qty_90d, 0) = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(m.market_avg_sales_qty_90d, 0) END AS sales_index_90d,
|
||||
CASE WHEN COALESCE(m.market_avg_price_usd_90d, 0) = 0 THEN 0 ELSE b.avg_price_usd_90d / NULLIF(m.market_avg_price_usd_90d, 0) END AS price_index_90d,
|
||||
CASE WHEN COALESCE(m.market_avg_margin_90d, 0) = 0 THEN 0 ELSE b.gross_margin_90d / NULLIF(m.market_avg_margin_90d, 0) END AS margin_index_90d
|
||||
FROM Base b
|
||||
LEFT JOIN Market m ON m.market_key=b.market_key
|
||||
LEFT JOIN Spread sp ON sp.product_code=b.product_code AND sp.color_code=b.color_code AND sp.yaka_kodu=b.yaka_kodu
|
||||
)
|
||||
INSERT INTO mk_product_performance_kpi_daily (
|
||||
kpi_date, product_code, color_code, yaka_kodu, item_description,
|
||||
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty,
|
||||
sales_qty_30d, sales_qty_90d, sales_qty_365d, sales_qty_730d,
|
||||
sales_usd_30d, sales_usd_90d, sales_usd_365d, avg_daily_sales_90d,
|
||||
stock_days_90d, avg_price_usd_90d, cost_price_usd, base_price_usd, base_price_try,
|
||||
gross_profit_usd_90d, gross_margin_90d, customer_count_90d,
|
||||
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d,
|
||||
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, avg_daily_sales_90d, avg_daily_sales_180d,
|
||||
stock_days_90d, stock_days_180d, avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try,
|
||||
gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d,
|
||||
unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d, market_count_90d, customer_count_90d,
|
||||
sales_index_90d, price_index_90d, margin_index_90d, performance_score,
|
||||
performance_bucket, recommendation, last_sale_date, last_ref_number, updated_at
|
||||
)
|
||||
@@ -527,16 +555,18 @@ SELECT
|
||||
$1::date,
|
||||
product_code, color_code, yaka_kodu, item_description,
|
||||
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty,
|
||||
COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0),
|
||||
COALESCE(sales_usd_30d, 0), COALESCE(sales_usd_90d, 0), COALESCE(sales_usd_365d, 0), COALESCE(avg_daily_sales_90d, 0),
|
||||
COALESCE(stock_days_90d, 0), COALESCE(avg_price_usd_90d, 0), COALESCE(cost_price_usd, 0), COALESCE(base_price_usd, 0), COALESCE(base_price_try, 0),
|
||||
COALESCE(gross_profit_usd_90d, 0), COALESCE(gross_margin_90d, 0), COALESCE(customer_count_90d, 0),
|
||||
COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_180d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0),
|
||||
COALESCE(sales_usd_30d, 0), COALESCE(sales_usd_90d, 0), COALESCE(sales_usd_180d, 0), COALESCE(sales_usd_365d, 0), COALESCE(avg_daily_sales_90d, 0), COALESCE(avg_daily_sales_180d, 0),
|
||||
COALESCE(stock_days_90d, 0), COALESCE(stock_days_180d, 0), COALESCE(avg_price_usd_90d, 0), COALESCE(avg_price_usd_180d, 0), COALESCE(cost_price_usd, 0), COALESCE(base_price_usd, 0), COALESCE(base_price_try, 0),
|
||||
COALESCE(gross_profit_usd_90d, 0), COALESCE(gross_profit_usd_180d, 0), COALESCE(gross_margin_90d, 0), COALESCE(gross_margin_180d, 0),
|
||||
COALESCE(unit_profit_cost_90d, 0), COALESCE(unit_profit_cost_180d, 0), COALESCE(unit_profit_base_90d, 0), COALESCE(unit_profit_base_180d, 0), COALESCE(market_count_90d, 0), COALESCE(customer_count_90d, 0),
|
||||
COALESCE(sales_index_90d, 0), COALESCE(price_index_90d, 0), COALESCE(margin_index_90d, 0),
|
||||
ROUND(
|
||||
LEAST(40, COALESCE(sales_index_90d, 0) * 20)
|
||||
LEAST(35, COALESCE(sales_index_90d, 0) * 18)
|
||||
+ LEAST(25, GREATEST(COALESCE(gross_margin_90d, 0), 0) * 50)
|
||||
+ LEAST(20, COALESCE(customer_count_90d, 0) * 2)
|
||||
+ CASE WHEN COALESCE(stock_days_90d, 0) BETWEEN 10 AND 90 THEN 15 WHEN COALESCE(stock_days_90d, 0) > 180 THEN -10 ELSE 0 END
|
||||
+ LEAST(10, COALESCE(market_count_90d, 0) * 3)
|
||||
+ CASE WHEN COALESCE(stock_days_90d, 0) BETWEEN 10 AND 90 THEN 10 WHEN COALESCE(stock_days_90d, 0) > 180 THEN -10 ELSE 0 END
|
||||
, 4) AS performance_score,
|
||||
CASE
|
||||
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'YILDIZ_URUN'
|
||||
|
||||
@@ -26,6 +26,8 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc {
|
||||
Kategori: strings.TrimSpace(r.URL.Query().Get("kategori")),
|
||||
Seri: strings.TrimSpace(r.URL.Query().Get("seri")),
|
||||
Bucket: strings.TrimSpace(r.URL.Query().Get("bucket")),
|
||||
SortBy: strings.TrimSpace(r.URL.Query().Get("sort_by")),
|
||||
Descending: boolQuery(r, "descending", true),
|
||||
Limit: intQuery(r, "limit", 100),
|
||||
Page: intQuery(r, "page", 1),
|
||||
}
|
||||
@@ -44,6 +46,46 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func GetProductPerformanceSalesDetailsHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
rows, err := queries.ListProductPerformanceSalesDetails(
|
||||
ctx,
|
||||
pg,
|
||||
strings.TrimSpace(r.URL.Query().Get("product_code")),
|
||||
strings.TrimSpace(r.URL.Query().Get("color_code")),
|
||||
strings.TrimSpace(r.URL.Query().Get("yaka_kodu")),
|
||||
intQuery(r, "limit", 100),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "urun satis detayi alinamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(rows)
|
||||
}
|
||||
}
|
||||
|
||||
func GetProductPerformanceStockSizesHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
rows, err := queries.ListProductPerformanceStockSizes(
|
||||
ctx,
|
||||
strings.TrimSpace(r.URL.Query().Get("product_code")),
|
||||
strings.TrimSpace(r.URL.Query().Get("color_code")),
|
||||
strings.TrimSpace(r.URL.Query().Get("yaka_kodu")),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "urun beden stok detayi alinamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(rows)
|
||||
}
|
||||
}
|
||||
|
||||
func GetProductPerformanceSummaryHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -166,6 +208,14 @@ func intQuery(r *http.Request, key string, fallback int) int {
|
||||
return n
|
||||
}
|
||||
|
||||
func boolQuery(r *http.Request, key string, fallback bool) bool {
|
||||
raw := strings.TrimSpace(strings.ToLower(r.URL.Query().Get(key)))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
|
||||
}
|
||||
|
||||
func parseDateOrZero(raw string) time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -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
|
||||
row-key="row_key"
|
||||
class="performance-table bg-white"
|
||||
:rows="rows"
|
||||
:rows="productTableRows"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@request="onRequest"
|
||||
>
|
||||
<template #body-cell-image="props">
|
||||
<q-td :props="props" class="product-image-cell">
|
||||
<template #header="props">
|
||||
<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)">
|
||||
<q-img
|
||||
v-if="getCachedProductImageUrl(props.row)"
|
||||
@@ -106,27 +155,13 @@
|
||||
</div>
|
||||
<q-tooltip>Foto</q-tooltip>
|
||||
</button>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-performance_bucket="props">
|
||||
<q-td :props="props">
|
||||
<q-badge :color="bucketColor(props.row.performance_bucket)">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
@@ -307,6 +342,60 @@
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -336,6 +425,13 @@ const imageDialogUrls = ref([])
|
||||
const imageDialogTitle = ref('')
|
||||
const imageDialogSubtitle = ref('')
|
||||
const imageSlide = ref(0)
|
||||
const performanceDialog = ref(false)
|
||||
const performanceDialogRow = ref(null)
|
||||
const performanceDialogTitle = ref('')
|
||||
const performanceDialogSubtitle = ref('')
|
||||
const detailLoading = ref(false)
|
||||
const detailSalesRows = ref([])
|
||||
const detailStockSizes = ref([])
|
||||
|
||||
const filters = reactive({
|
||||
q: '',
|
||||
@@ -344,6 +440,8 @@ const filters = reactive({
|
||||
seri: '',
|
||||
bucket: ''
|
||||
})
|
||||
const columnFilters = reactive({})
|
||||
const expandedGroups = ref({})
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
@@ -368,6 +466,23 @@ const customerBreakdownOptions = [
|
||||
{ label: 'Piyasa > Ülke > Müşteri', value: 'market_country_customer' }
|
||||
]
|
||||
|
||||
const detailStockColumns = [
|
||||
{ name: 'size_code', label: 'Beden', field: 'size_code', align: 'left', sortable: true },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }
|
||||
]
|
||||
|
||||
const detailSalesColumns = [
|
||||
{ name: 'sales_date', label: 'Tarih', field: 'sales_date', align: 'left', sortable: true },
|
||||
{ name: 'ref_number', label: 'Ref', field: 'ref_number', align: 'left', sortable: true },
|
||||
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
|
||||
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
|
||||
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
|
||||
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
|
||||
{ name: 'sales_qty', label: 'Adet', field: row => formatNumber(row.sales_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd', label: 'USD', field: row => formatMoney(row.sales_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd, 'USD'), align: 'right', sortable: true }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
@@ -379,12 +494,26 @@ const columns = [
|
||||
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
|
||||
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' },
|
||||
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
|
||||
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_180d', label: '180G Satış', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_180d', label: '180G USD', field: 'sales_usd_180d', align: 'right', sortable: true },
|
||||
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true },
|
||||
{ name: 'stock_days_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 1), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' },
|
||||
{ name: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' },
|
||||
{ name: 'base_price_usd', label: 'Ort. USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd', label: 'Ort. USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'unit_profit_base_90d', label: '90G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_90d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'unit_profit_cost_90d', label: '90G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_90d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true },
|
||||
{ name: 'gross_margin_180d', label: '180G Marj', field: 'gross_margin_180d', align: 'right', sortable: true },
|
||||
{ name: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'customer_count_90d', label: '90G Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true },
|
||||
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
|
||||
@@ -465,6 +594,8 @@ const summaryCards = computed(() => [
|
||||
{ key: 'rows', label: 'Satır', value: formatNumber(summary.value.total_rows, 0) },
|
||||
{ key: 'star', label: 'Yıldız', value: formatNumber(summary.value.star_count, 0) },
|
||||
{ key: 'risk', label: 'Stok Riski', value: formatNumber(summary.value.stock_risk, 0) },
|
||||
{ key: 'markets', label: '90G Piyasa', value: formatNumber(summary.value.market_count_90d, 0) },
|
||||
{ key: 'customers', label: '90G Müşteri', value: formatNumber(summary.value.customer_count_90d, 0) },
|
||||
{ key: 'stock_cost', label: 'Stok Maliyeti', value: formatMoney(summary.value.stock_cost_usd, 'USD') },
|
||||
{ key: 'risk_cost', label: 'Risk Maliyeti', value: formatMoney(summary.value.risk_cost_usd, 'USD') }
|
||||
])
|
||||
@@ -481,6 +612,203 @@ const idleRows = computed(() => rows.value
|
||||
))
|
||||
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0)))
|
||||
|
||||
const performanceCards = computed(() => {
|
||||
const row = performanceDialogRow.value || {}
|
||||
return [
|
||||
{ key: 'score', label: 'Skor', value: formatNumber(row.performance_score, 1) },
|
||||
{ key: 'stock', label: 'Stok', value: formatNumber(row.stock_qty, 0) },
|
||||
{ key: 'sales90', label: '90G Satış', value: formatNumber(row.sales_qty_90d, 0) },
|
||||
{ key: 'sales180', label: '180G Satış', value: formatNumber(row.sales_qty_180d, 0) },
|
||||
{ key: 'markets', label: '90G Piyasa', value: formatNumber(row.market_count_90d, 0) },
|
||||
{ key: 'customers', label: '90G Müşteri', value: formatNumber(row.customer_count_90d, 0) },
|
||||
{ key: 'avg', label: 'Ort. USD Satış', value: formatMoney(row.avg_price_usd_90d, 'USD') },
|
||||
{ key: 'base', label: 'Taban Maliyet', value: formatMoney(row.base_price_usd, 'USD') },
|
||||
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(row.cost_price_usd, 'USD') },
|
||||
{ key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(row.unit_profit_base_90d, 'USD') },
|
||||
{ key: 'profitCost', label: 'P.Başı Çıplak K/Z', value: formatMoney(row.unit_profit_cost_90d, 'USD') },
|
||||
{ key: 'margin', label: '90G Marj', value: formatPercent(row.gross_margin_90d) },
|
||||
{ key: 'bucket', label: 'Durum', value: bucketLabel(row.performance_bucket) }
|
||||
]
|
||||
})
|
||||
|
||||
const filterableColumnNames = new Set([
|
||||
'product_code',
|
||||
'color_code',
|
||||
'yaka_kodu',
|
||||
'item_description',
|
||||
'kategori',
|
||||
'urun_ilk_grubu',
|
||||
'askili_yan',
|
||||
'urun_ana_grubu',
|
||||
'urun_alt_grubu',
|
||||
'market_key',
|
||||
'performance_bucket',
|
||||
'recommendation'
|
||||
])
|
||||
|
||||
const groupLevels = [
|
||||
{ key: 'kategori', label: 'Kategori' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün Kodu' },
|
||||
{ key: 'color_code', label: 'Renk' }
|
||||
]
|
||||
|
||||
const filteredProductRows = computed(() => rows.value.filter(row => {
|
||||
return columns.every(col => {
|
||||
if (!isColumnFilterable(col.name)) return true
|
||||
const needle = String(columnFilters[col.name] || '').trim().toLocaleLowerCase('tr-TR')
|
||||
if (!needle) return true
|
||||
return String(rawProductCellValue(row, col.name) || '').toLocaleLowerCase('tr-TR').includes(needle)
|
||||
})
|
||||
}))
|
||||
|
||||
const productTableRows = computed(() => {
|
||||
const out = []
|
||||
appendGroupRows(out, filteredProductRows.value, 0, [])
|
||||
return out
|
||||
})
|
||||
|
||||
function appendGroupRows (out, sourceRows, level, parentKeys) {
|
||||
if (level >= groupLevels.length) {
|
||||
out.push(...sourceRows)
|
||||
return
|
||||
}
|
||||
|
||||
const groupDef = groupLevels[level]
|
||||
const grouped = new Map()
|
||||
for (const row of sourceRows) {
|
||||
const value = normalizeGroupValue(rawProductCellValue(row, groupDef.key) || row[groupDef.fallback])
|
||||
if (!grouped.has(value)) grouped.set(value, [])
|
||||
grouped.get(value).push(row)
|
||||
}
|
||||
|
||||
Array.from(grouped.entries())
|
||||
.sort((a, b) => a[0].localeCompare(b[0], 'tr'))
|
||||
.forEach(([value, groupRows]) => {
|
||||
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
|
||||
out.push(makeGroupRow(key, level, `${groupDef.label}: ${value}`, groupRows))
|
||||
if (isGroupExpanded(key)) {
|
||||
appendGroupRows(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeGroupRow (key, level, label, groupRows) {
|
||||
return {
|
||||
__group: true,
|
||||
row_key: `group|${key}`,
|
||||
key,
|
||||
level,
|
||||
label,
|
||||
count: groupRows.length,
|
||||
stock_qty: sumRows(groupRows, 'stock_qty'),
|
||||
sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'),
|
||||
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
|
||||
sales_usd_90d: sumRows(groupRows, 'sales_usd_90d'),
|
||||
sales_usd_180d: sumRows(groupRows, 'sales_usd_180d'),
|
||||
market_count_90d: distinctCount(groupRows, 'market_key'),
|
||||
customer_count_90d: sumRows(groupRows, 'customer_count_90d'),
|
||||
avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'),
|
||||
avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'),
|
||||
avg_base_price_usd: averageRows(groupRows, 'base_price_usd'),
|
||||
avg_cost_price_usd: averageRows(groupRows, 'cost_price_usd'),
|
||||
avg_gross_margin_90d: averageRows(groupRows, 'gross_margin_90d')
|
||||
}
|
||||
}
|
||||
|
||||
function sumRows (sourceRows, field) {
|
||||
return sourceRows.reduce((sum, row) => sum + Number(row[field] || 0), 0)
|
||||
}
|
||||
|
||||
function distinctCount (sourceRows, field) {
|
||||
const values = new Set()
|
||||
for (const row of sourceRows) {
|
||||
const value = String(row[field] || '').trim()
|
||||
if (value && value !== '-') values.add(value)
|
||||
}
|
||||
return values.size
|
||||
}
|
||||
|
||||
function averageRows (sourceRows, field) {
|
||||
const values = sourceRows.map(row => Number(row[field] || 0)).filter(value => Number.isFinite(value) && value !== 0)
|
||||
if (!values.length) return 0
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function weightedAverage (sourceRows, amountField, qtyField) {
|
||||
const qty = sumRows(sourceRows, qtyField)
|
||||
if (!qty) return 0
|
||||
return sumRows(sourceRows, amountField) / qty
|
||||
}
|
||||
|
||||
function normalizeGroupValue (value) {
|
||||
const text = String(value || '').trim()
|
||||
return text || '-'
|
||||
}
|
||||
|
||||
function isGroupExpanded (key) {
|
||||
return expandedGroups.value[key] === true
|
||||
}
|
||||
|
||||
function toggleGroup (key) {
|
||||
expandedGroups.value = {
|
||||
...expandedGroups.value,
|
||||
[key]: !isGroupExpanded(key)
|
||||
}
|
||||
}
|
||||
|
||||
function isColumnFilterable (name) {
|
||||
return filterableColumnNames.has(name)
|
||||
}
|
||||
|
||||
function rawProductCellValue (row, name) {
|
||||
switch (name) {
|
||||
case 'urun_ilk_grubu': return row.urun_ilk_grubu || row.yas_grubu || ''
|
||||
case 'urun_ana_grubu': return row.urun_ana_grubu || row.seri || ''
|
||||
case 'performance_bucket': return bucketLabel(row.performance_bucket)
|
||||
default: return row[name]
|
||||
}
|
||||
}
|
||||
|
||||
function formatProductCell (row, name) {
|
||||
switch (name) {
|
||||
case 'stock_qty':
|
||||
case 'sales_qty_90d':
|
||||
case 'sales_qty_180d':
|
||||
case 'market_count_90d':
|
||||
case 'customer_count_90d':
|
||||
return formatNumber(row[name], 0)
|
||||
case 'stock_days_90d':
|
||||
case 'stock_days_180d':
|
||||
case 'sales_index_90d':
|
||||
case 'performance_score':
|
||||
return formatNumber(row[name], name === 'performance_score' ? 1 : 2)
|
||||
case 'sales_usd_90d':
|
||||
case 'sales_usd_180d':
|
||||
case 'avg_price_usd_90d':
|
||||
case 'avg_price_usd_180d':
|
||||
case 'base_price_usd':
|
||||
case 'cost_price_usd':
|
||||
case 'unit_profit_base_90d':
|
||||
case 'unit_profit_cost_90d':
|
||||
case 'unit_profit_base_180d':
|
||||
case 'unit_profit_cost_180d':
|
||||
return formatMoney(row[name], 'USD')
|
||||
case 'gross_margin_90d':
|
||||
case 'gross_margin_180d':
|
||||
return formatPercent(row[name])
|
||||
case 'urun_ilk_grubu':
|
||||
return row.urun_ilk_grubu || row.yas_grubu || ''
|
||||
case 'urun_ana_grubu':
|
||||
return row.urun_ana_grubu || row.seri || ''
|
||||
default:
|
||||
return rawProductCellValue(row, name) || ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRow (row) {
|
||||
const product = String(row?.product_code || '').trim()
|
||||
const color = String(row?.color_code || '').trim()
|
||||
@@ -595,6 +923,41 @@ async function openProductImageDialog (row) {
|
||||
imageDialog.value = true
|
||||
}
|
||||
|
||||
async function openPerformanceDialog (row) {
|
||||
performanceDialogRow.value = row
|
||||
performanceDialogTitle.value = String(row?.product_code || '-')
|
||||
performanceDialogSubtitle.value = [
|
||||
row?.item_description,
|
||||
row?.color_code ? `Renk: ${row.color_code}` : '',
|
||||
row?.yaka_kodu ? `Yaka: ${row.yaka_kodu}` : '',
|
||||
row?.market_key ? `Piyasa: ${row.market_key}` : ''
|
||||
].filter(Boolean).join(' | ')
|
||||
performanceDialog.value = true
|
||||
detailLoading.value = true
|
||||
detailSalesRows.value = []
|
||||
detailStockSizes.value = []
|
||||
try {
|
||||
const params = {
|
||||
product_code: row?.product_code || '',
|
||||
color_code: row?.color_code || '',
|
||||
yaka_kodu: row?.yaka_kodu || ''
|
||||
}
|
||||
const [salesResp, stockResp] = await Promise.all([
|
||||
api.get('/pricing/product-performance/sales-details', { params: { ...params, limit: 200 }, timeout: 60000 }),
|
||||
api.get('/pricing/product-performance/stock-sizes', { params, timeout: 60000 })
|
||||
])
|
||||
detailSalesRows.value = (Array.isArray(salesResp?.data) ? salesResp.data : []).map((item, idx) => ({
|
||||
...item,
|
||||
detail_key: `${item.sales_date}|${item.ref_number}|${item.customer_code}|${idx}`
|
||||
}))
|
||||
detailStockSizes.value = Array.isArray(stockResp?.data) ? stockResp.data : []
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün detay analizi alınamadı' })
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload () {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -608,7 +971,9 @@ async function reload () {
|
||||
product_code: filters.product_code || undefined,
|
||||
kategori: filters.kategori || undefined,
|
||||
seri: filters.seri || undefined,
|
||||
bucket: filters.bucket || undefined
|
||||
bucket: filters.bucket || undefined,
|
||||
sort_by: pagination.value.sortBy || undefined,
|
||||
descending: pagination.value.descending ? 'true' : 'false'
|
||||
},
|
||||
timeout: 60000
|
||||
}),
|
||||
@@ -709,6 +1074,35 @@ onMounted(reload)
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.performance-table :deep(thead tr:nth-child(2) th) {
|
||||
top: 40px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.table-filter-row :deep(.q-field__control) {
|
||||
min-height: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.header-filter {
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.group-row td {
|
||||
background: #eef2f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.group-meta {
|
||||
margin-left: 12px;
|
||||
color: #5f6b7a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-image-cell {
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
|
||||
Reference in New Issue
Block a user