Compare commits
76
Commits
38c168f7bd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db13574e9d | ||
|
|
ba9a744aef | ||
|
|
82ad3783b3 | ||
|
|
322bed753f | ||
|
|
bdc9459330 | ||
|
|
2d85a33623 | ||
|
|
5f1c37859b | ||
|
|
31a71ab566 | ||
|
|
25ef023df5 | ||
|
|
0c51729f7a | ||
|
|
20f5fea2e8 | ||
|
|
65e8aa2b62 | ||
|
|
1f8355b77c | ||
|
|
11048f1555 | ||
|
|
b83a3d22ce | ||
|
|
77af9ef533 | ||
|
|
4395d6bae2 | ||
|
|
6bc19e7a43 | ||
|
|
797d069e59 | ||
|
|
36e0ec9ff5 | ||
|
|
f14a96a024 | ||
|
|
f877b611b7 | ||
|
|
dbb3f3f380 | ||
|
|
03287d4850 | ||
|
|
12ce3a3eca | ||
|
|
52420bc5ff | ||
|
|
d5aa43ccb5 | ||
|
|
04461acb58 | ||
|
|
cd101811c4 | ||
|
|
c4028010c0 | ||
|
|
c2d010751f | ||
|
|
a9ab9fd9b0 | ||
|
|
1945fd4456 | ||
|
|
7b8f8905e5 | ||
|
|
89df3d2d3d | ||
|
|
474904481f | ||
|
|
6eaf0e3ed3 | ||
|
|
8249761415 | ||
|
|
dbd84f7399 | ||
|
|
9edc806345 | ||
|
|
7a7008cc1b | ||
|
|
3e0253f6be | ||
|
|
b80b5024f4 | ||
|
|
5eaf8be9e6 | ||
|
|
cc2930caf2 | ||
|
|
26b4a63c45 | ||
|
|
c6a3d1552e | ||
|
|
005d2eafac | ||
|
|
289f204752 | ||
|
|
1448dab9d7 | ||
|
|
5d4216d42e | ||
|
|
3993c363cb | ||
|
|
a0da3f2bed | ||
|
|
19a42e0551 | ||
|
|
53cdd1a4a1 | ||
|
|
fe29e763c5 | ||
|
|
9e1ecb9b3f | ||
|
|
fbdde54eee | ||
|
|
b2113a8d49 | ||
|
|
28e33d5e58 | ||
|
|
fbdc4934ff | ||
|
|
5b512fd30a | ||
|
|
7879172c54 | ||
|
|
c602e1e119 | ||
|
|
27934bccdf | ||
|
|
48bde09c2c | ||
|
|
480cad0d6f | ||
|
|
9f6e288d04 | ||
|
|
d0717900a6 | ||
|
|
25bc9b2d02 | ||
|
|
4c56062ef4 | ||
|
|
60b2f76537 | ||
|
|
81384426ff | ||
|
|
3697e35064 | ||
|
|
4ae6475875 | ||
|
|
5f006b8be0 |
Generated
+1
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="GoModuleSettings" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
|
||||
@@ -3,6 +3,8 @@ bssapp
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.argebaggi.local
|
||||
mail.env
|
||||
|
||||
# Runtime fonts
|
||||
|
||||
@@ -66,10 +66,11 @@ func main() {
|
||||
log.Fatalf("refresh failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("product performance refresh done: sales=%d stock=%d kpi=%d\n",
|
||||
fmt.Printf("product performance refresh done: sales=%d stock=%d kpi=%d snapshots=%d\n",
|
||||
result.SalesRows,
|
||||
result.StockRows,
|
||||
result.KpiRows,
|
||||
result.SnapshotRows,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
var MssqlDB *sql.DB
|
||||
var UretimDB *sql.DB
|
||||
var ArgeBaggiDB *sql.DB
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
@@ -158,3 +159,41 @@ func ConnectMSSQLUretim() error {
|
||||
func GetUretimDB() *sql.DB {
|
||||
return UretimDB
|
||||
}
|
||||
|
||||
// ConnectMSSQLArgeBaggi initializes the optional ArgeBAGGI connection.
|
||||
// Credentials must be supplied through ARGEBAGGI_MSSQL_CONN; never keep them
|
||||
// in source control.
|
||||
func ConnectMSSQLArgeBaggi() error {
|
||||
connString := strings.TrimSpace(os.Getenv("ARGEBAGGI_MSSQL_CONN"))
|
||||
if connString == "" {
|
||||
return fmt.Errorf("ARGEBAGGI_MSSQL_CONN tanimli degil")
|
||||
}
|
||||
|
||||
connectionTimeoutSec := envInt("ARGEBAGGI_MSSQL_CONNECTION_TIMEOUT_SEC", 10)
|
||||
dialTimeoutSec := envInt("ARGEBAGGI_MSSQL_DIAL_TIMEOUT_SEC", connectionTimeoutSec)
|
||||
connString = ensureMSSQLTimeouts(connString, connectionTimeoutSec, dialTimeoutSec)
|
||||
|
||||
var err error
|
||||
ArgeBaggiDB, err = sql.Open("sqlserver", connString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ArgeBAGGI MSSQL baglanti hatasi: %w", err)
|
||||
}
|
||||
|
||||
ArgeBaggiDB.SetMaxOpenConns(envInt("ARGEBAGGI_MSSQL_MAX_OPEN_CONNS", 10))
|
||||
ArgeBaggiDB.SetMaxIdleConns(envInt("ARGEBAGGI_MSSQL_MAX_IDLE_CONNS", 5))
|
||||
ArgeBaggiDB.SetConnMaxLifetime(time.Duration(envInt("ARGEBAGGI_MSSQL_CONN_MAX_LIFETIME_MIN", 30)) * time.Minute)
|
||||
ArgeBaggiDB.SetConnMaxIdleTime(time.Duration(envInt("ARGEBAGGI_MSSQL_CONN_MAX_IDLE_MIN", 10)) * time.Minute)
|
||||
|
||||
if err = ArgeBaggiDB.Ping(); err != nil {
|
||||
_ = ArgeBaggiDB.Close()
|
||||
ArgeBaggiDB = nil
|
||||
return fmt.Errorf("ArgeBAGGI MSSQL erisilemiyor: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("ArgeBAGGI MSSQL baglantisi basarili (connection timeout=%ds, dial timeout=%ds)\n", connectionTimeoutSec, dialTimeoutSec)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetArgeBaggiDB() *sql.DB {
|
||||
return ArgeBaggiDB
|
||||
}
|
||||
|
||||
+41
@@ -227,6 +227,7 @@ InitRoutes — FULL V3 (Method-aware) PERMISSION EDITION
|
||||
func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router {
|
||||
|
||||
r := mux.NewRouter()
|
||||
mountUploads(r)
|
||||
mountSPA(r)
|
||||
|
||||
/*
|
||||
@@ -939,6 +940,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/export-excel", "POST",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.ExportProductPerformanceExcelHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/summary", "GET",
|
||||
"pricing", "view",
|
||||
@@ -999,6 +1005,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceGroupedHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/grouped/filter-options", "POST",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceGroupedFilterOptionsHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/sales-details", "GET",
|
||||
"pricing", "view",
|
||||
@@ -1379,6 +1390,7 @@ func main() {
|
||||
// (e.g. SSH-tunnel PostgreSQL on 127.0.0.1:15432).
|
||||
if runtime.GOOS == "windows" {
|
||||
_ = godotenv.Overload(".env.local")
|
||||
_ = godotenv.Overload(".env.argebaggi.local")
|
||||
}
|
||||
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
@@ -1406,6 +1418,15 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.ConnectMSSQLArgeBaggi(); err != nil {
|
||||
if strings.Contains(err.Error(), "ARGEBAGGI_MSSQL_CONN tanimli degil") {
|
||||
log.Println("ArgeBAGGI DB baglantisi atlandi: ARGEBAGGI_MSSQL_CONN tanimli degil")
|
||||
} else {
|
||||
// This source enriches one screen; a temporary outage must not stop
|
||||
// the whole application from starting.
|
||||
log.Println("ArgeBAGGI DB baglantisi basarisiz:", err)
|
||||
}
|
||||
}
|
||||
|
||||
pgDB, err := db.ConnectPostgres()
|
||||
if err != nil {
|
||||
@@ -1507,6 +1528,26 @@ func main() {
|
||||
|
||||
}
|
||||
|
||||
func mountUploads(r *mux.Router) {
|
||||
root := strings.TrimSpace(os.Getenv("BLOB_ROOT"))
|
||||
if root == "" {
|
||||
return
|
||||
}
|
||||
uploadsRoot := filepath.Join(root, "uploads")
|
||||
if fi, err := os.Stat(uploadsRoot); err != nil || !fi.IsDir() {
|
||||
return
|
||||
}
|
||||
fileServer := http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadsRoot)))
|
||||
r.PathPrefix("/uploads/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})).Methods(http.MethodGet, http.MethodHead, http.MethodOptions)
|
||||
}
|
||||
|
||||
func mountSPA(r *mux.Router) {
|
||||
r.NotFoundHandler = http.HandlerFunc(spaIndex)
|
||||
r.HandleFunc("/", spaIndex).Methods(http.MethodGet)
|
||||
|
||||
+174
-138
@@ -1,68 +1,85 @@
|
||||
package models
|
||||
|
||||
type ProductPerformanceRow struct {
|
||||
KpiDate string `json:"kpi_date"`
|
||||
ProductCode string `json:"product_code"`
|
||||
ColorCode string `json:"color_code"`
|
||||
ColorDescription string `json:"color_description"`
|
||||
YakaKodu string `json:"yaka_kodu"`
|
||||
ItemDescription string `json:"item_description"`
|
||||
Kategori string `json:"kategori"`
|
||||
Seri string `json:"seri"`
|
||||
YasGrubu string `json:"yas_grubu"`
|
||||
AskiliYan string `json:"askili_yan"`
|
||||
UrunIlkGrubu string `json:"urun_ilk_grubu"`
|
||||
UrunAnaGrubu string `json:"urun_ana_grubu"`
|
||||
UrunAltGrubu string `json:"urun_alt_grubu"`
|
||||
MarketKey string `json:"market_key"`
|
||||
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"`
|
||||
SalesQtyTotal float64 `json:"sales_qty_total"`
|
||||
SalesUSD30 float64 `json:"sales_usd_30d"`
|
||||
SalesUSD90 float64 `json:"sales_usd_90d"`
|
||||
SalesUSD180 float64 `json:"sales_usd_180d"`
|
||||
SalesUSD365 float64 `json:"sales_usd_365d"`
|
||||
SalesUSDTotal float64 `json:"sales_usd_total"`
|
||||
AvgDailySales90 float64 `json:"avg_daily_sales_90d"`
|
||||
AvgDailySales180 float64 `json:"avg_daily_sales_180d"`
|
||||
AvgDailySales365 float64 `json:"avg_daily_sales_365d"`
|
||||
AvgDailySalesTotal float64 `json:"avg_daily_sales_total"`
|
||||
StockDays90 float64 `json:"stock_days_90d"`
|
||||
StockDays180 float64 `json:"stock_days_180d"`
|
||||
StockDays365 float64 `json:"stock_days_365d"`
|
||||
StockDaysTotal float64 `json:"stock_days_total"`
|
||||
StockTurnover90 float64 `json:"stock_turnover_90d"`
|
||||
StockTurnover180 float64 `json:"stock_turnover_180d"`
|
||||
StockTurnover365 float64 `json:"stock_turnover_365d"`
|
||||
StockTurnoverTotal float64 `json:"stock_turnover_total"`
|
||||
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"`
|
||||
MarginIndex90 float64 `json:"margin_index_90d"`
|
||||
PerformanceScore float64 `json:"performance_score"`
|
||||
PerformanceBucket string `json:"performance_bucket"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
LastSaleDate string `json:"last_sale_date"`
|
||||
LastRefNumber string `json:"last_ref_number"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
KpiDate string `json:"kpi_date"`
|
||||
ProductCode string `json:"product_code"`
|
||||
ColorCode string `json:"color_code"`
|
||||
ColorDescription string `json:"color_description"`
|
||||
YakaKodu string `json:"yaka_kodu"`
|
||||
ItemDescription string `json:"item_description"`
|
||||
Kategori string `json:"kategori"`
|
||||
Seri string `json:"seri"`
|
||||
YasGrubu string `json:"yas_grubu"`
|
||||
AskiliYan string `json:"askili_yan"`
|
||||
UrunIlkGrubu string `json:"urun_ilk_grubu"`
|
||||
UrunAnaGrubu string `json:"urun_ana_grubu"`
|
||||
UrunAltGrubu string `json:"urun_alt_grubu"`
|
||||
MarketKey string `json:"market_key"`
|
||||
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"`
|
||||
SalesQtyTotal float64 `json:"sales_qty_total"`
|
||||
SalesUSD30 float64 `json:"sales_usd_30d"`
|
||||
SalesUSD90 float64 `json:"sales_usd_90d"`
|
||||
SalesUSD180 float64 `json:"sales_usd_180d"`
|
||||
SalesUSD365 float64 `json:"sales_usd_365d"`
|
||||
SalesUSDTotal float64 `json:"sales_usd_total"`
|
||||
AvgDailySales90 float64 `json:"avg_daily_sales_90d"`
|
||||
AvgDailySales180 float64 `json:"avg_daily_sales_180d"`
|
||||
AvgDailySales365 float64 `json:"avg_daily_sales_365d"`
|
||||
AvgDailySalesTotal float64 `json:"avg_daily_sales_total"`
|
||||
AvgStock90 float64 `json:"avg_stock_90d"`
|
||||
AvgStock180 float64 `json:"avg_stock_180d"`
|
||||
AvgStock365 float64 `json:"avg_stock_365d"`
|
||||
AvgStockTotal float64 `json:"avg_stock_total"`
|
||||
StockDays90 float64 `json:"stock_days_90d"`
|
||||
StockDays180 float64 `json:"stock_days_180d"`
|
||||
StockDays365 float64 `json:"stock_days_365d"`
|
||||
StockDaysTotal float64 `json:"stock_days_total"`
|
||||
StockTurnover90 float64 `json:"stock_turnover_90d"`
|
||||
StockTurnover180 float64 `json:"stock_turnover_180d"`
|
||||
StockTurnover365 float64 `json:"stock_turnover_365d"`
|
||||
StockTurnoverTotal float64 `json:"stock_turnover_total"`
|
||||
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"`
|
||||
MarketCount180 int `json:"market_count_180d"`
|
||||
MarketCount365 int `json:"market_count_365d"`
|
||||
MarketCountTotal int `json:"market_count_total"`
|
||||
CustomerCount90 int `json:"customer_count_90d"`
|
||||
CustomerCount180 int `json:"customer_count_180d"`
|
||||
CustomerCount365 int `json:"customer_count_365d"`
|
||||
CustomerCountTotal int `json:"customer_count_total"`
|
||||
SalesIndex90 float64 `json:"sales_index_90d"`
|
||||
SalesIndex180 float64 `json:"sales_index_180d"`
|
||||
SalesIndex365 float64 `json:"sales_index_365d"`
|
||||
SalesIndexTotal float64 `json:"sales_index_total"`
|
||||
PriceIndex90 float64 `json:"price_index_90d"`
|
||||
MarginIndex90 float64 `json:"margin_index_90d"`
|
||||
PerformanceScore90 float64 `json:"performance_score_90d"`
|
||||
PerformanceScore180 float64 `json:"performance_score_180d"`
|
||||
PerformanceScore365 float64 `json:"performance_score_365d"`
|
||||
PerformanceScoreTotal float64 `json:"performance_score_total"`
|
||||
PerformanceScore float64 `json:"performance_score"`
|
||||
PerformanceBucket string `json:"performance_bucket"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
LastSaleDate string `json:"last_sale_date"`
|
||||
LastRefNumber string `json:"last_ref_number"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ProductPerformanceSummary struct {
|
||||
@@ -308,82 +325,101 @@ type ProductPerformanceCustomerRow struct {
|
||||
}
|
||||
|
||||
type ProductPerformanceSalesBreakdownRow struct {
|
||||
Breakdown string `json:"breakdown"`
|
||||
ProductCode string `json:"product_code"`
|
||||
ColorCode string `json:"color_code"`
|
||||
ColorDescription string `json:"color_description"`
|
||||
YakaKodu string `json:"yaka_kodu"`
|
||||
ItemDescription string `json:"item_description"`
|
||||
Kategori string `json:"kategori"`
|
||||
AskiliYan string `json:"askili_yan"`
|
||||
UrunIlkGrubu string `json:"urun_ilk_grubu"`
|
||||
UrunAnaGrubu string `json:"urun_ana_grubu"`
|
||||
UrunAltGrubu string `json:"urun_alt_grubu"`
|
||||
MarketKey string `json:"market_key"`
|
||||
Country string `json:"country"`
|
||||
CustomerSegment string `json:"customer_segment"`
|
||||
CustomerCode string `json:"customer_code"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
ProductCount int `json:"product_count"`
|
||||
CustomerCount90 int `json:"customer_count_90d"`
|
||||
InvoiceCount90 int `json:"invoice_count_90d"`
|
||||
SalesQty90 float64 `json:"sales_qty_90d"`
|
||||
SalesUSD90 float64 `json:"sales_usd_90d"`
|
||||
AvgPriceUSD90 float64 `json:"avg_price_usd_90d"`
|
||||
BasePriceUSD90 float64 `json:"base_price_usd_90d"`
|
||||
CostPriceUSD90 float64 `json:"cost_price_usd_90d"`
|
||||
GrossProfitBase90 float64 `json:"gross_profit_base_usd_90d"`
|
||||
GrossProfitCost90 float64 `json:"gross_profit_cost_usd_90d"`
|
||||
GrossMarginBase90 float64 `json:"gross_margin_base_90d"`
|
||||
GrossMarginCost90 float64 `json:"gross_margin_cost_90d"`
|
||||
CustomerCount180 int `json:"customer_count_180d"`
|
||||
InvoiceCount180 int `json:"invoice_count_180d"`
|
||||
SalesQty180 float64 `json:"sales_qty_180d"`
|
||||
SalesUSD180 float64 `json:"sales_usd_180d"`
|
||||
AvgPriceUSD180 float64 `json:"avg_price_usd_180d"`
|
||||
BasePriceUSD180 float64 `json:"base_price_usd_180d"`
|
||||
CostPriceUSD180 float64 `json:"cost_price_usd_180d"`
|
||||
GrossProfitBase180 float64 `json:"gross_profit_base_usd_180d"`
|
||||
GrossProfitCost180 float64 `json:"gross_profit_cost_usd_180d"`
|
||||
GrossMarginBase180 float64 `json:"gross_margin_base_180d"`
|
||||
GrossMarginCost180 float64 `json:"gross_margin_cost_180d"`
|
||||
SalesQty365 float64 `json:"sales_qty_365d"`
|
||||
SalesUSD365 float64 `json:"sales_usd_365d"`
|
||||
AvgPriceUSD365 float64 `json:"avg_price_usd_365d"`
|
||||
BasePriceUSD365 float64 `json:"base_price_usd_365d"`
|
||||
CostPriceUSD365 float64 `json:"cost_price_usd_365d"`
|
||||
GrossProfitBase365 float64 `json:"gross_profit_base_usd_365d"`
|
||||
GrossProfitCost365 float64 `json:"gross_profit_cost_usd_365d"`
|
||||
GrossMarginBase365 float64 `json:"gross_margin_base_365d"`
|
||||
GrossMarginCost365 float64 `json:"gross_margin_cost_365d"`
|
||||
CustomerCount365 int `json:"customer_count_365d"`
|
||||
InvoiceCount365 int `json:"invoice_count_365d"`
|
||||
CustomerCountTotal int `json:"customer_count_total"`
|
||||
InvoiceCountTotal int `json:"invoice_count_total"`
|
||||
SalesQtyTotal float64 `json:"sales_qty_total"`
|
||||
SalesUSDTotal float64 `json:"sales_usd_total"`
|
||||
AvgPriceUSDTotal float64 `json:"avg_price_usd_total"`
|
||||
BasePriceUSDTotal float64 `json:"base_price_usd_total"`
|
||||
CostPriceUSDTotal float64 `json:"cost_price_usd_total"`
|
||||
GrossProfitBaseTotal float64 `json:"gross_profit_base_usd_total"`
|
||||
GrossProfitCostTotal float64 `json:"gross_profit_cost_usd_total"`
|
||||
GrossMarginBaseTotal float64 `json:"gross_margin_base_total"`
|
||||
GrossMarginCostTotal float64 `json:"gross_margin_cost_total"`
|
||||
StockQty float64 `json:"stock_qty"`
|
||||
StockTurnover90 float64 `json:"stock_turnover_90d"`
|
||||
StockTurnover180 float64 `json:"stock_turnover_180d"`
|
||||
StockTurnover365 float64 `json:"stock_turnover_365d"`
|
||||
StockTurnoverTotal float64 `json:"stock_turnover_total"`
|
||||
HasCost bool `json:"has_cost"`
|
||||
SalesIndex90 float64 `json:"sales_index_90d"`
|
||||
CustomerScore90 float64 `json:"customer_score_90d"`
|
||||
CustomerScore180 float64 `json:"customer_score_180d"`
|
||||
CustomerScore365 float64 `json:"customer_score_365d"`
|
||||
CustomerScoreTotal float64 `json:"customer_score_total"`
|
||||
PerformanceScore float64 `json:"performance_score"`
|
||||
PerformanceBucket string `json:"performance_bucket"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
LastSaleDate string `json:"last_sale_date"`
|
||||
Breakdown string `json:"breakdown"`
|
||||
ProductCode string `json:"product_code"`
|
||||
ColorCode string `json:"color_code"`
|
||||
ColorDescription string `json:"color_description"`
|
||||
YakaKodu string `json:"yaka_kodu"`
|
||||
ItemDescription string `json:"item_description"`
|
||||
Kategori string `json:"kategori"`
|
||||
AskiliYan string `json:"askili_yan"`
|
||||
UrunIlkGrubu string `json:"urun_ilk_grubu"`
|
||||
UrunAnaGrubu string `json:"urun_ana_grubu"`
|
||||
UrunAltGrubu string `json:"urun_alt_grubu"`
|
||||
MarketKey string `json:"market_key"`
|
||||
Country string `json:"country"`
|
||||
CustomerSegment string `json:"customer_segment"`
|
||||
CustomerCode string `json:"customer_code"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
ProductCount int `json:"product_count"`
|
||||
ProductGroupCount90 int `json:"product_group_count_90d"`
|
||||
ProductGroupCount180 int `json:"product_group_count_180d"`
|
||||
ProductGroupCount365 int `json:"product_group_count_365d"`
|
||||
ProductGroupCountTotal int `json:"product_group_count_total"`
|
||||
MarketCount90 int `json:"market_count_90d"`
|
||||
MarketCount180 int `json:"market_count_180d"`
|
||||
MarketCount365 int `json:"market_count_365d"`
|
||||
MarketCountTotal int `json:"market_count_total"`
|
||||
CustomerCount90 int `json:"customer_count_90d"`
|
||||
InvoiceCount90 int `json:"invoice_count_90d"`
|
||||
SalesQty90 float64 `json:"sales_qty_90d"`
|
||||
SalesUSD90 float64 `json:"sales_usd_90d"`
|
||||
AvgPriceUSD90 float64 `json:"avg_price_usd_90d"`
|
||||
BasePriceUSD90 float64 `json:"base_price_usd_90d"`
|
||||
CostPriceUSD90 float64 `json:"cost_price_usd_90d"`
|
||||
GrossProfitBase90 float64 `json:"gross_profit_base_usd_90d"`
|
||||
GrossProfitCost90 float64 `json:"gross_profit_cost_usd_90d"`
|
||||
GrossMarginBase90 float64 `json:"gross_margin_base_90d"`
|
||||
GrossMarginCost90 float64 `json:"gross_margin_cost_90d"`
|
||||
CustomerCount180 int `json:"customer_count_180d"`
|
||||
InvoiceCount180 int `json:"invoice_count_180d"`
|
||||
SalesQty180 float64 `json:"sales_qty_180d"`
|
||||
SalesUSD180 float64 `json:"sales_usd_180d"`
|
||||
AvgPriceUSD180 float64 `json:"avg_price_usd_180d"`
|
||||
BasePriceUSD180 float64 `json:"base_price_usd_180d"`
|
||||
CostPriceUSD180 float64 `json:"cost_price_usd_180d"`
|
||||
GrossProfitBase180 float64 `json:"gross_profit_base_usd_180d"`
|
||||
GrossProfitCost180 float64 `json:"gross_profit_cost_usd_180d"`
|
||||
GrossMarginBase180 float64 `json:"gross_margin_base_180d"`
|
||||
GrossMarginCost180 float64 `json:"gross_margin_cost_180d"`
|
||||
SalesQty365 float64 `json:"sales_qty_365d"`
|
||||
SalesUSD365 float64 `json:"sales_usd_365d"`
|
||||
AvgPriceUSD365 float64 `json:"avg_price_usd_365d"`
|
||||
BasePriceUSD365 float64 `json:"base_price_usd_365d"`
|
||||
CostPriceUSD365 float64 `json:"cost_price_usd_365d"`
|
||||
GrossProfitBase365 float64 `json:"gross_profit_base_usd_365d"`
|
||||
GrossProfitCost365 float64 `json:"gross_profit_cost_usd_365d"`
|
||||
GrossMarginBase365 float64 `json:"gross_margin_base_365d"`
|
||||
GrossMarginCost365 float64 `json:"gross_margin_cost_365d"`
|
||||
CustomerCount365 int `json:"customer_count_365d"`
|
||||
InvoiceCount365 int `json:"invoice_count_365d"`
|
||||
CustomerCountTotal int `json:"customer_count_total"`
|
||||
InvoiceCountTotal int `json:"invoice_count_total"`
|
||||
SalesQtyTotal float64 `json:"sales_qty_total"`
|
||||
SalesUSDTotal float64 `json:"sales_usd_total"`
|
||||
AvgPriceUSDTotal float64 `json:"avg_price_usd_total"`
|
||||
BasePriceUSDTotal float64 `json:"base_price_usd_total"`
|
||||
CostPriceUSDTotal float64 `json:"cost_price_usd_total"`
|
||||
GrossProfitBaseTotal float64 `json:"gross_profit_base_usd_total"`
|
||||
GrossProfitCostTotal float64 `json:"gross_profit_cost_usd_total"`
|
||||
GrossMarginBaseTotal float64 `json:"gross_margin_base_total"`
|
||||
GrossMarginCostTotal float64 `json:"gross_margin_cost_total"`
|
||||
StockQty float64 `json:"stock_qty"`
|
||||
AvgStock90 float64 `json:"avg_stock_90d"`
|
||||
AvgStock180 float64 `json:"avg_stock_180d"`
|
||||
AvgStock365 float64 `json:"avg_stock_365d"`
|
||||
AvgStockTotal float64 `json:"avg_stock_total"`
|
||||
StockTurnover90 float64 `json:"stock_turnover_90d"`
|
||||
StockTurnover180 float64 `json:"stock_turnover_180d"`
|
||||
StockTurnover365 float64 `json:"stock_turnover_365d"`
|
||||
StockTurnoverTotal float64 `json:"stock_turnover_total"`
|
||||
HasCost bool `json:"has_cost"`
|
||||
SalesIndex90 float64 `json:"sales_index_90d"`
|
||||
SalesIndex180 float64 `json:"sales_index_180d"`
|
||||
SalesIndex365 float64 `json:"sales_index_365d"`
|
||||
SalesIndexTotal float64 `json:"sales_index_total"`
|
||||
CustomerScore90 float64 `json:"customer_score_90d"`
|
||||
CustomerScore180 float64 `json:"customer_score_180d"`
|
||||
CustomerScore365 float64 `json:"customer_score_365d"`
|
||||
CustomerScoreTotal float64 `json:"customer_score_total"`
|
||||
PerformanceScore90 float64 `json:"performance_score_90d"`
|
||||
PerformanceScore180 float64 `json:"performance_score_180d"`
|
||||
PerformanceScore365 float64 `json:"performance_score_365d"`
|
||||
PerformanceScoreTotal float64 `json:"performance_score_total"`
|
||||
PerformanceScore float64 `json:"performance_score"`
|
||||
PerformanceBucket string `json:"performance_bucket"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
LastSaleDate string `json:"last_sale_date"`
|
||||
}
|
||||
|
||||
type ProductPerformanceSalesDetailRow struct {
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
package models
|
||||
|
||||
type ProductionNoCostProductRow struct {
|
||||
UretimSekli string `json:"UretimSekli"`
|
||||
UrtSiparisNo string `json:"nUrtSiparisNo"`
|
||||
IslemTarihi string `json:"dteIslemTarihi"`
|
||||
FirmaKodu string `json:"FirmaKodu"`
|
||||
FirmaAdi string `json:"FirmaAdi"`
|
||||
SonIsEmriVeren string `json:"SonIsEmriVeren"`
|
||||
ModelAdi string `json:"sAdi"`
|
||||
Kodu string `json:"sKodu"`
|
||||
SKullaniciAdi string `json:"sKullaniciAdi"`
|
||||
SKullaniciGunc string `json:"sKullaniciAdiGunc"`
|
||||
MMiktarG float64 `json:"lMMiktar_G"`
|
||||
MModelKodu string `json:"sMModelKodu"`
|
||||
UretimSekli string `json:"UretimSekli"`
|
||||
UrtSiparisNo string `json:"nUrtSiparisNo"`
|
||||
IslemTarihi string `json:"dteIslemTarihi"`
|
||||
FirmaKodu string `json:"FirmaKodu"`
|
||||
FirmaAdi string `json:"FirmaAdi"`
|
||||
SonIsEmriVeren string `json:"SonIsEmriVeren"`
|
||||
ModelAdi string `json:"sAdi"`
|
||||
Kodu string `json:"sKodu"`
|
||||
SKullaniciAdi string `json:"sKullaniciAdi"`
|
||||
SKullaniciGunc string `json:"sKullaniciAdiGunc"`
|
||||
MMiktarG float64 `json:"lMMiktar_G"`
|
||||
CeketDepoGiris float64 `json:"ceketDepoGiris"`
|
||||
PantolonDepoGiris float64 `json:"pantolonDepoGiris"`
|
||||
YelekDepoGiris float64 `json:"yelekDepoGiris"`
|
||||
MModelKodu string `json:"sMModelKodu"`
|
||||
}
|
||||
|
||||
type ProductionHasCostProductRow struct {
|
||||
UretimSekli string `json:"UretimSekli"`
|
||||
UrtSiparisNo string `json:"nUrtSiparisNo"`
|
||||
CeketDepoGiris float64 `json:"ceketDepoGiris"`
|
||||
PantolonDepoGiris float64 `json:"pantolonDepoGiris"`
|
||||
YelekDepoGiris float64 `json:"yelekDepoGiris"`
|
||||
NOnMLNo string `json:"nOnMLNo"`
|
||||
UrunKodu string `json:"UrunKodu"`
|
||||
UrunAdi string `json:"UrunAdi"`
|
||||
|
||||
@@ -26,6 +26,12 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
|
||||
deltaHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_DELTA_HHMM", "02:00")
|
||||
deltaTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_TIMEOUT_HOURS", 6, 1, 24)
|
||||
runOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_DELTA_RUN_ON_STARTUP", false)
|
||||
fullEnabled := productPerformanceEnvBool("PRODUCT_PERFORMANCE_FULL_ENABLED", false)
|
||||
fullWeekday := productPerformanceEnvWeekday("PRODUCT_PERFORMANCE_FULL_WEEKDAY", time.Saturday)
|
||||
fullHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_FULL_HHMM", "16:00")
|
||||
fullStartDate := productPerformanceEnvDate("PRODUCT_PERFORMANCE_FULL_START_DATE", time.Date(2022, 1, 1, 0, 0, 0, 0, time.Local))
|
||||
fullTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_FULL_TIMEOUT_HOURS", 18, 1, 72)
|
||||
fullRunOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_FULL_RUN_ON_STARTUP", false)
|
||||
|
||||
var running int32
|
||||
runDelta := func(reason string) {
|
||||
@@ -49,12 +55,40 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
|
||||
log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d duration_ms=%d",
|
||||
reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.DurationMS)
|
||||
log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d snapshots=%d duration_ms=%d",
|
||||
reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.SnapshotRows, result.DurationMS)
|
||||
}
|
||||
runFull := func(reason string) {
|
||||
if !atomic.CompareAndSwapInt32(&running, 0, 1) {
|
||||
log.Printf("[ProductPerformanceJob] skip (%s): already running", reason)
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&running, 0)
|
||||
|
||||
endDate := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(fullTimeoutHours)*time.Hour)
|
||||
defer cancel()
|
||||
|
||||
result, err := queries.RefreshProductPerformance(ctx, pgDB, queries.ProductPerformanceRefreshRequest{
|
||||
Mode: "full",
|
||||
Stage: "all",
|
||||
StartDate: fullStartDate,
|
||||
EndDate: endDate,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d snapshots=%d duration_ms=%d",
|
||||
reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.SnapshotRows, result.DurationMS)
|
||||
}
|
||||
|
||||
log.Printf("[ProductPerformanceJob] scheduled daily_delta=%s lookback_days=%d run_delta_on_startup=%t",
|
||||
deltaHHMM, deltaDays, runOnStartup)
|
||||
if fullEnabled {
|
||||
log.Printf("[ProductPerformanceJob] scheduled weekly_full=%s %s start_date=%s timeout_hours=%d run_full_on_startup=%t",
|
||||
fullWeekday.String(), fullHHMM, fullStartDate.Format("2006-01-02"), fullTimeoutHours, fullRunOnStartup)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if runOnStartup {
|
||||
@@ -69,6 +103,21 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
|
||||
runDelta("daily-delta")
|
||||
}
|
||||
}()
|
||||
if fullEnabled {
|
||||
go func() {
|
||||
if fullRunOnStartup {
|
||||
time.Sleep(30 * time.Second)
|
||||
runFull("startup-full")
|
||||
}
|
||||
|
||||
for {
|
||||
next := productPerformanceNextWeekly(time.Now(), fullWeekday, fullHHMM)
|
||||
log.Printf("[ProductPerformanceJob] weekly full next_at=%s in=%s", next.Format(time.RFC3339), time.Until(next).Round(time.Second))
|
||||
time.Sleep(time.Until(next))
|
||||
runFull("weekly-full")
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceNextDaily(now time.Time, hhmm string) time.Time {
|
||||
@@ -80,6 +129,16 @@ func productPerformanceNextDaily(now time.Time, hhmm string) time.Time {
|
||||
return next
|
||||
}
|
||||
|
||||
func productPerformanceNextWeekly(now time.Time, weekday time.Weekday, hhmm string) time.Time {
|
||||
hour, minute := productPerformanceParseHHMM(hhmm, 16, 0)
|
||||
daysUntil := (int(weekday) - int(now.Weekday()) + 7) % 7
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()).AddDate(0, 0, daysUntil)
|
||||
if !next.After(now) {
|
||||
next = next.AddDate(0, 0, 7)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func productPerformanceParseHHMM(raw string, fallbackHour, fallbackMinute int) (int, int) {
|
||||
parts := strings.Split(strings.TrimSpace(raw), ":")
|
||||
if len(parts) != 2 {
|
||||
@@ -120,3 +179,36 @@ func productPerformanceEnvBool(name string, fallback bool) bool {
|
||||
}
|
||||
return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
|
||||
}
|
||||
|
||||
func productPerformanceEnvDate(name string, fallback time.Time) time.Time {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", raw, time.Local)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func productPerformanceEnvWeekday(name string, fallback time.Weekday) time.Weekday {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
|
||||
case "sunday", "sun", "pazar", "0":
|
||||
return time.Sunday
|
||||
case "monday", "mon", "pazartesi", "1":
|
||||
return time.Monday
|
||||
case "tuesday", "tue", "sali", "salı", "2":
|
||||
return time.Tuesday
|
||||
case "wednesday", "wed", "carsamba", "çarşamba", "3":
|
||||
return time.Wednesday
|
||||
case "thursday", "thu", "persembe", "perşembe", "4":
|
||||
return time.Thursday
|
||||
case "friday", "fri", "cuma", "5":
|
||||
return time.Friday
|
||||
case "saturday", "sat", "cumartesi", "6":
|
||||
return time.Saturday
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
+3257
-277
@@ -8,7 +8,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -39,16 +42,19 @@ type ProductPerformanceRefreshRequest struct {
|
||||
}
|
||||
|
||||
type ProductPerformanceRefreshResult struct {
|
||||
Mode string `json:"mode"`
|
||||
Stage string `json:"stage"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
SalesRows int `json:"sales_rows"`
|
||||
StockRows int `json:"stock_rows"`
|
||||
KpiRows int `json:"kpi_rows"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Mode string `json:"mode"`
|
||||
Stage string `json:"stage"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
SalesRows int `json:"sales_rows"`
|
||||
StockRows int `json:"stock_rows"`
|
||||
KpiRows int `json:"kpi_rows"`
|
||||
SnapshotRows int `json:"snapshot_rows"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
type productPerformanceSnapshotBypassKey struct{}
|
||||
|
||||
func productPerformanceStockQuery() string {
|
||||
return `
|
||||
;WITH ActiveWarehouses AS (
|
||||
@@ -367,10 +373,10 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_sales_daily (
|
||||
UPDATE mk_product_performance_sales_daily
|
||||
SET urun_ilk_grubu = '', updated_at = now()
|
||||
WHERE btrim(urun_ilk_grubu) = '-'
|
||||
OR upper(translate(btrim(urun_ilk_grubu), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`,
|
||||
OR upper(translate(btrim(urun_ilk_grubu), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`,
|
||||
`
|
||||
DELETE FROM mk_product_performance_sales_daily
|
||||
WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`,
|
||||
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`,
|
||||
`
|
||||
UPDATE mk_product_performance_sales_daily
|
||||
SET askili_yan = '', updated_at = now()
|
||||
@@ -439,15 +445,15 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_price_dim (
|
||||
`
|
||||
UPDATE mk_product_performance_price_dim
|
||||
SET
|
||||
cost_price_usd = GREATEST(cost_price_usd, base_price_usd),
|
||||
base_price_usd = LEAST(cost_price_usd, base_price_usd),
|
||||
cost_price_usd = LEAST(cost_price_usd, base_price_usd),
|
||||
base_price_usd = GREATEST(cost_price_usd, base_price_usd),
|
||||
updated_at = now()
|
||||
WHERE cost_price_usd > 0
|
||||
AND base_price_usd > 0
|
||||
AND cost_price_usd < base_price_usd`,
|
||||
AND cost_price_usd > base_price_usd`,
|
||||
`
|
||||
DELETE FROM mk_product_performance_price_dim
|
||||
WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`,
|
||||
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
|
||||
kpi_date DATE NOT NULL,
|
||||
@@ -475,6 +481,10 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
|
||||
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,
|
||||
avg_stock_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
avg_stock_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
avg_stock_365d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
avg_stock_total 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,
|
||||
@@ -491,8 +501,17 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
|
||||
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,
|
||||
market_count_180d INTEGER NOT NULL DEFAULT 0,
|
||||
market_count_365d INTEGER NOT NULL DEFAULT 0,
|
||||
market_count_total INTEGER NOT NULL DEFAULT 0,
|
||||
customer_count_90d INTEGER NOT NULL DEFAULT 0,
|
||||
customer_count_180d INTEGER NOT NULL DEFAULT 0,
|
||||
customer_count_365d INTEGER NOT NULL DEFAULT 0,
|
||||
customer_count_total INTEGER NOT NULL DEFAULT 0,
|
||||
sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
sales_index_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
sales_index_365d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
sales_index_total NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
price_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
margin_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
performance_score NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
@@ -514,27 +533,27 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
|
||||
UPDATE mk_product_performance_kpi_daily
|
||||
SET urun_ilk_grubu = '', updated_at = now()
|
||||
WHERE btrim(urun_ilk_grubu) = '-'
|
||||
OR upper(translate(btrim(urun_ilk_grubu), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`,
|
||||
OR upper(translate(btrim(urun_ilk_grubu), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`,
|
||||
`
|
||||
DELETE FROM mk_product_performance_kpi_daily
|
||||
WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`,
|
||||
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`,
|
||||
`
|
||||
UPDATE mk_product_performance_kpi_daily
|
||||
SET
|
||||
cost_price_usd = GREATEST(cost_price_usd, base_price_usd),
|
||||
base_price_usd = LEAST(cost_price_usd, base_price_usd),
|
||||
gross_profit_usd_90d = COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * GREATEST(cost_price_usd, base_price_usd)),
|
||||
gross_profit_usd_180d = COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * GREATEST(cost_price_usd, base_price_usd)),
|
||||
gross_margin_90d = CASE WHEN COALESCE(sales_usd_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * GREATEST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_90d,0) END,
|
||||
gross_margin_180d = CASE WHEN COALESCE(sales_usd_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * GREATEST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_180d,0) END,
|
||||
unit_profit_cost_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - GREATEST(cost_price_usd, base_price_usd) END,
|
||||
unit_profit_cost_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - GREATEST(cost_price_usd, base_price_usd) END,
|
||||
unit_profit_base_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - LEAST(cost_price_usd, base_price_usd) END,
|
||||
unit_profit_base_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - LEAST(cost_price_usd, base_price_usd) END,
|
||||
cost_price_usd = LEAST(cost_price_usd, base_price_usd),
|
||||
base_price_usd = GREATEST(cost_price_usd, base_price_usd),
|
||||
gross_profit_usd_90d = COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * LEAST(cost_price_usd, base_price_usd)),
|
||||
gross_profit_usd_180d = COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * LEAST(cost_price_usd, base_price_usd)),
|
||||
gross_margin_90d = CASE WHEN COALESCE(sales_usd_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * LEAST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_90d,0) END,
|
||||
gross_margin_180d = CASE WHEN COALESCE(sales_usd_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * LEAST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_180d,0) END,
|
||||
unit_profit_cost_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - LEAST(cost_price_usd, base_price_usd) END,
|
||||
unit_profit_cost_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - LEAST(cost_price_usd, base_price_usd) END,
|
||||
unit_profit_base_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - GREATEST(cost_price_usd, base_price_usd) END,
|
||||
unit_profit_base_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - GREATEST(cost_price_usd, base_price_usd) END,
|
||||
updated_at = now()
|
||||
WHERE cost_price_usd > 0
|
||||
AND base_price_usd > 0
|
||||
AND cost_price_usd < base_price_usd`,
|
||||
AND cost_price_usd > base_price_usd`,
|
||||
`
|
||||
UPDATE mk_product_performance_kpi_daily
|
||||
SET askili_yan = '', updated_at = now()
|
||||
@@ -546,6 +565,10 @@ WHERE btrim(askili_yan) = '-'`,
|
||||
`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 avg_daily_sales_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_total 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 stock_days_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
@@ -561,6 +584,62 @@ WHERE btrim(askili_yan) = '-'`,
|
||||
`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`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_180d INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_365d INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_total INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS customer_count_180d INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS customer_count_365d INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS customer_count_total INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_index_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_index_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_index_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot (
|
||||
report_key TEXT NOT NULL,
|
||||
row_order INTEGER NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT pk_mk_product_performance_report_snapshot PRIMARY KEY (report_key, row_order)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_report_snapshot_key ON mk_product_performance_report_snapshot (report_key, row_order)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot_meta (
|
||||
report_key TEXT PRIMARY KEY,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot (
|
||||
report_key TEXT NOT NULL,
|
||||
row_order INTEGER NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
group_levels_key TEXT NOT NULL,
|
||||
main_group TEXT NOT NULL DEFAULT '',
|
||||
group_level INTEGER NOT NULL DEFAULT 0,
|
||||
group_key TEXT NOT NULL DEFAULT '',
|
||||
parent_key TEXT NOT NULL DEFAULT '',
|
||||
group_field TEXT NOT NULL DEFAULT '',
|
||||
group_value TEXT NOT NULL DEFAULT '',
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT pk_mk_product_performance_grouped_snapshot PRIMARY KEY (report_key, row_order)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_key ON mk_product_performance_grouped_snapshot (report_key, row_order)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_level ON mk_product_performance_grouped_snapshot (report_key, group_level, row_order)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_field ON mk_product_performance_grouped_snapshot (report_key, group_field, group_value)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot_meta (
|
||||
report_key TEXT PRIMARY KEY,
|
||||
mode TEXT NOT NULL,
|
||||
group_levels_key TEXT NOT NULL,
|
||||
main_group TEXT NOT NULL DEFAULT '',
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := pg.Exec(stmt); err != nil {
|
||||
@@ -570,6 +649,523 @@ WHERE btrim(askili_yan) = '-'`,
|
||||
return nil
|
||||
}
|
||||
|
||||
func productPerformanceSnapshotBypass(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, productPerformanceSnapshotBypassKey{}, true)
|
||||
}
|
||||
|
||||
func productPerformanceUseSnapshot(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(productPerformanceSnapshotBypassKey{}).(bool)
|
||||
return !v
|
||||
}
|
||||
|
||||
func productPerformanceLiveFallbackEnabled() bool {
|
||||
raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_PERFORMANCE_LIVE_FALLBACK")))
|
||||
return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
|
||||
}
|
||||
|
||||
func productPerformanceSnapshotKey(parts ...string) string {
|
||||
cleaned := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.ToLower(strings.TrimSpace(part))
|
||||
if part != "" {
|
||||
cleaned = append(cleaned, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(cleaned, ":")
|
||||
}
|
||||
|
||||
func loadProductPerformanceSnapshotRows[T any](ctx context.Context, pg *sql.DB, reportKey string, limit int) ([]T, bool, error) {
|
||||
if !productPerformanceUseSnapshot(ctx) || pg == nil || strings.TrimSpace(reportKey) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
if limit <= 0 || limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT payload
|
||||
FROM mk_product_performance_report_snapshot
|
||||
WHERE report_key = $1
|
||||
ORDER BY row_order
|
||||
LIMIT $2
|
||||
`, reportKey, limit)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]T, 0, limit)
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var item T
|
||||
if err := json.Unmarshal(raw, &item); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out, true, nil
|
||||
}
|
||||
var exists bool
|
||||
if err := pg.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM mk_product_performance_report_snapshot_meta
|
||||
WHERE report_key = $1
|
||||
)
|
||||
`, reportKey).Scan(&exists); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !exists && !productPerformanceLiveFallbackEnabled() {
|
||||
return out, true, nil
|
||||
}
|
||||
return out, exists, nil
|
||||
}
|
||||
|
||||
func loadProductPerformanceSnapshotMapRows(ctx context.Context, pg *sql.DB, reportKey string, limit int) ([]map[string]any, bool, error) {
|
||||
if !productPerformanceUseSnapshot(ctx) || pg == nil || strings.TrimSpace(reportKey) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
if limit <= 0 || limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT payload
|
||||
FROM mk_product_performance_report_snapshot
|
||||
WHERE report_key = $1
|
||||
ORDER BY row_order
|
||||
LIMIT $2
|
||||
`, reportKey, limit)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]map[string]any, 0, limit)
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal(raw, &item); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if item == nil {
|
||||
item = map[string]any{}
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out, true, nil
|
||||
}
|
||||
var exists bool
|
||||
if err := pg.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM mk_product_performance_report_snapshot_meta
|
||||
WHERE report_key = $1
|
||||
)
|
||||
`, reportKey).Scan(&exists); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !exists && !productPerformanceLiveFallbackEnabled() {
|
||||
return out, true, nil
|
||||
}
|
||||
return out, exists, nil
|
||||
}
|
||||
|
||||
func loadProductPerformanceSnapshotItem[T any](ctx context.Context, pg *sql.DB, reportKey string) (T, bool, error) {
|
||||
var zero T
|
||||
rows, ok, err := loadProductPerformanceSnapshotRows[T](ctx, pg, reportKey, 1)
|
||||
if err != nil || !ok || len(rows) == 0 {
|
||||
return zero, ok, err
|
||||
}
|
||||
return rows[0], true, nil
|
||||
}
|
||||
|
||||
func saveProductPerformanceSnapshotRows[T any](ctx context.Context, pg *sql.DB, reportKey string, rows []T, started time.Time) error {
|
||||
if pg == nil || strings.TrimSpace(reportKey) == "" {
|
||||
return nil
|
||||
}
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM mk_product_performance_report_snapshot WHERE report_key = $1`, reportKey); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO mk_product_performance_report_snapshot (report_key, row_order, payload, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for i, row := range rows {
|
||||
raw, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := stmt.ExecContext(ctx, reportKey, i, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_product_performance_report_snapshot_meta (report_key, row_count, refreshed_at, duration_ms)
|
||||
VALUES ($1, $2, now(), $3)
|
||||
ON CONFLICT (report_key) DO UPDATE SET
|
||||
row_count = EXCLUDED.row_count,
|
||||
refreshed_at = EXCLUDED.refreshed_at,
|
||||
duration_ms = EXCLUDED.duration_ms
|
||||
`, reportKey, len(rows), time.Since(started).Milliseconds()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func saveProductPerformanceGroupedSnapshotRows(ctx context.Context, pg *sql.DB, reportKey string, def productPerformanceGroupedSnapshotDefinition, rows []map[string]any, started time.Time) error {
|
||||
if pg == nil || strings.TrimSpace(reportKey) == "" {
|
||||
return nil
|
||||
}
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot WHERE report_key = $1`, reportKey); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO mk_product_performance_grouped_snapshot (
|
||||
report_key, row_order, mode, group_levels_key, main_group,
|
||||
group_level, group_key, parent_key, group_field, group_value, payload, updated_at
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now())
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
levelsKey := productPerformanceGroupedLevelsKey(def.Levels)
|
||||
for i, row := range rows {
|
||||
raw, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := stringFromMap(row, "key")
|
||||
if _, err := stmt.ExecContext(
|
||||
ctx,
|
||||
reportKey,
|
||||
i,
|
||||
strings.TrimSpace(def.Mode),
|
||||
levelsKey,
|
||||
strings.TrimSpace(def.MainGroup),
|
||||
intFromMap(row, "level"),
|
||||
key,
|
||||
productPerformanceParentGroupKey(key),
|
||||
stringFromMap(row, "group_field"),
|
||||
stringFromMap(row, "group_value"),
|
||||
raw,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_product_performance_grouped_snapshot_meta (
|
||||
report_key, mode, group_levels_key, main_group, row_count, refreshed_at, duration_ms
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,now(),$6)
|
||||
ON CONFLICT (report_key) DO UPDATE SET
|
||||
mode = EXCLUDED.mode,
|
||||
group_levels_key = EXCLUDED.group_levels_key,
|
||||
main_group = EXCLUDED.main_group,
|
||||
row_count = EXCLUDED.row_count,
|
||||
refreshed_at = EXCLUDED.refreshed_at,
|
||||
duration_ms = EXCLUDED.duration_ms
|
||||
`, reportKey, strings.TrimSpace(def.Mode), levelsKey, strings.TrimSpace(def.MainGroup), len(rows), time.Since(started).Milliseconds()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func RebuildProductPerformanceReportSnapshots(ctx context.Context, pg *sql.DB) (int, error) {
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
snapshotReadCtx := ctx
|
||||
ctx = productPerformanceSnapshotBypass(ctx)
|
||||
total := 0
|
||||
|
||||
save := func(reportKey string, rows any, started time.Time) error {
|
||||
rowCount := productPerformanceSnapshotPayloadLen(rows)
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot save start key=%s rows=%d", reportKey, rowCount)
|
||||
defer func() {
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot save done key=%s rows=%d elapsed=%s", reportKey, rowCount, time.Since(started).Round(time.Second))
|
||||
}()
|
||||
switch v := rows.(type) {
|
||||
case []models.ProductPerformanceSummary:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
case []models.ProductPerformanceRow:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
case []models.ProductPerformanceGeneralRow:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
case []models.ProductPerformanceOrderAnalysisRow:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
case []models.ProductPerformanceOrderGroupRow:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
case []models.ProductPerformanceOrderProductCustomerRow:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
case []models.ProductPerformanceOrderMarketDetailRow:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
case []models.ProductPerformanceMarketRow:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
case []models.ProductPerformanceCountryRow:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
case []models.ProductPerformanceCustomerRow:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
case []models.ProductPerformanceSalesBreakdownRow:
|
||||
total += len(v)
|
||||
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
||||
default:
|
||||
return fmt.Errorf("unsupported product performance snapshot payload %s", reportKey)
|
||||
}
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("summary"))
|
||||
if summary, err := GetProductPerformanceSummary(ctx, pg); err != nil {
|
||||
return total, err
|
||||
} else if err := save(productPerformanceSnapshotKey("summary"), []models.ProductPerformanceSummary{summary}, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
started = time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("products"))
|
||||
if rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: 50000, Page: 1, SortBy: "performance_score", Descending: true}); err != nil {
|
||||
return total, err
|
||||
} else if err := save(productPerformanceSnapshotKey("products"), rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
started = time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("general"))
|
||||
if rows, err := ListProductPerformanceGeneral(ctx, pg, 50000); err != nil {
|
||||
return total, err
|
||||
} else if err := save(productPerformanceSnapshotKey("general"), rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
started = time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("orders"))
|
||||
if rows, err := ListProductPerformanceOrderAnalysis(ctx, pg, 50000); err != nil {
|
||||
return total, err
|
||||
} else if err := save(productPerformanceSnapshotKey("orders"), rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
for _, mode := range []string{"market", "customer"} {
|
||||
started = time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("order-groups", mode))
|
||||
rows, err := ListProductPerformanceOrderGroups(ctx, pg, mode, 50000)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if err := save(productPerformanceSnapshotKey("order-groups", mode), rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
started = time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("order-product-customers"))
|
||||
if rows, err := ListProductPerformanceOrderProductCustomers(ctx, pg, 50000); err != nil {
|
||||
return total, err
|
||||
} else if err := save(productPerformanceSnapshotKey("order-product-customers"), rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
started = time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("order-market-details"))
|
||||
if rows, err := ListProductPerformanceOrderMarketDetails(ctx, pg, 50000); err != nil {
|
||||
return total, err
|
||||
} else if err := save(productPerformanceSnapshotKey("order-market-details"), rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
started = time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("markets"))
|
||||
if rows, err := ListProductPerformanceMarkets(ctx, pg, 50000); err != nil {
|
||||
return total, err
|
||||
} else if err := save(productPerformanceSnapshotKey("markets"), rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
started = time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("countries"))
|
||||
if rows, err := ListProductPerformanceCountries(ctx, pg, 50000); err != nil {
|
||||
return total, err
|
||||
} else if err := save(productPerformanceSnapshotKey("countries"), rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
for _, mode := range []string{"market_customer", "country_customer", "market_country_customer"} {
|
||||
started = time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("customers", mode))
|
||||
rows, err := ListProductPerformanceCustomers(ctx, pg, mode, 50000)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if err := save(productPerformanceSnapshotKey("customers", mode), rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
for _, mode := range []string{"color_yaka_market_customer", "product_country_segment_market_customer", "market_customer_product", "country_segment_market_customer_product"} {
|
||||
started = time.Now()
|
||||
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("sales-breakdown", mode))
|
||||
rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, mode, 50000)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if err := save(productPerformanceSnapshotKey("sales-breakdown", mode), rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
log.Printf("[ProductPerformanceRefresh] grouped snapshots rebuild start")
|
||||
groupedRows, err := RebuildProductPerformanceGroupedSnapshots(snapshotReadCtx, pg)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
log.Printf("[ProductPerformanceRefresh] grouped snapshots rebuild done rows=%d", groupedRows)
|
||||
total += groupedRows
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func productPerformanceSnapshotPayloadLen(rows any) int {
|
||||
switch v := rows.(type) {
|
||||
case []models.ProductPerformanceSummary:
|
||||
return len(v)
|
||||
case []models.ProductPerformanceRow:
|
||||
return len(v)
|
||||
case []models.ProductPerformanceGeneralRow:
|
||||
return len(v)
|
||||
case []models.ProductPerformanceOrderAnalysisRow:
|
||||
return len(v)
|
||||
case []models.ProductPerformanceOrderGroupRow:
|
||||
return len(v)
|
||||
case []models.ProductPerformanceOrderProductCustomerRow:
|
||||
return len(v)
|
||||
case []models.ProductPerformanceOrderMarketDetailRow:
|
||||
return len(v)
|
||||
case []models.ProductPerformanceMarketRow:
|
||||
return len(v)
|
||||
case []models.ProductPerformanceCountryRow:
|
||||
return len(v)
|
||||
case []models.ProductPerformanceCustomerRow:
|
||||
return len(v)
|
||||
case []models.ProductPerformanceSalesBreakdownRow:
|
||||
return len(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
type productPerformanceGroupedSnapshotDefinition struct {
|
||||
Mode string
|
||||
Levels []string
|
||||
MainGroup string
|
||||
}
|
||||
|
||||
func RebuildProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB) (int, error) {
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defs, err := productPerformanceGroupedSnapshotDefinitions(ctx, pg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
log.Printf("[ProductPerformanceRefresh] grouped snapshot definitions ready count=%d", len(defs))
|
||||
if err := deleteStaleProductPerformanceGroupedSnapshots(ctx, pg, productPerformanceSnapshotKey("grouped", "product_detail")+":%"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := 0
|
||||
for i, def := range defs {
|
||||
started := time.Now()
|
||||
levels := sanitizeProductPerformanceGroupLevels(def.Levels)
|
||||
if len(levels) == 0 {
|
||||
levels = defaultProductPerformanceGroupLevels(def.Mode)
|
||||
}
|
||||
reportKey := productPerformanceGroupedSnapshotReportKey(def.Mode, levels, def.MainGroup)
|
||||
log.Printf("[ProductPerformanceRefresh] grouped snapshot start %d/%d key=%s mode=%s main_group=%s levels=%s", i+1, len(defs), reportKey, def.Mode, def.MainGroup, productPerformanceGroupedLevelsKey(levels))
|
||||
sourceRows, err := productPerformanceGroupedRawSnapshotSourceRows(ctx, pg, def.Mode, 50000)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
log.Printf("[ProductPerformanceRefresh] grouped snapshot source loaded key=%s source_rows=%d elapsed=%s", reportKey, len(sourceRows), time.Since(started).Round(time.Second))
|
||||
req := ProductPerformanceGroupedRequest{
|
||||
Mode: def.Mode,
|
||||
MainGroup: def.MainGroup,
|
||||
}
|
||||
sourceRows = filterProductPerformanceGroupedRows(sourceRows, productPerformanceGroupedEffectiveFilters(req))
|
||||
groupStarted := time.Now()
|
||||
rows := buildProductPerformanceGroupedSnapshotRows(sourceRows, levels, def.Mode)
|
||||
log.Printf("[ProductPerformanceRefresh] grouped snapshot tree built key=%s rows=%d elapsed=%s total_elapsed=%s", reportKey, len(rows), time.Since(groupStarted).Round(time.Second), time.Since(started).Round(time.Second))
|
||||
if err := saveProductPerformanceGroupedSnapshotRows(ctx, pg, reportKey, productPerformanceGroupedSnapshotDefinition{
|
||||
Mode: def.Mode,
|
||||
Levels: levels,
|
||||
MainGroup: def.MainGroup,
|
||||
}, rows, started); err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += len(rows)
|
||||
log.Printf("[ProductPerformanceRefresh] grouped snapshot done key=%s rows=%d cumulative_rows=%d elapsed=%s", reportKey, len(rows), total, time.Since(started).Round(time.Second))
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func deleteStaleProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB, reportKeyLike string) error {
|
||||
reportKeyLike = strings.TrimSpace(reportKeyLike)
|
||||
if reportKeyLike == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := pg.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot WHERE report_key LIKE $1`, reportKeyLike); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := pg.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot_meta WHERE report_key LIKE $1`, reportKeyLike); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func productPerformanceGroupedSnapshotDefinitions(ctx context.Context, pg *sql.DB) ([]productPerformanceGroupedSnapshotDefinition, error) {
|
||||
_ = ctx
|
||||
_ = pg
|
||||
defs := []productPerformanceGroupedSnapshotDefinition{
|
||||
{Mode: "products", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key"}},
|
||||
{Mode: "products", Levels: []string{"urun_ana_grubu"}},
|
||||
{Mode: "idle", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
|
||||
{Mode: "sales_color_yaka_market_customer", Levels: []string{"urun_ana_grubu", "color_yaka", "market_key", "customer_code", "customer_name", "urun_alt_grubu", "product_code"}},
|
||||
{Mode: "sales_product_country_segment_market_customer", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "country", "customer_segment", "market_key", "customer_code", "customer_name"}},
|
||||
{Mode: "sales_country_segment_market_customer_product", Levels: []string{"country", "customer_segment", "market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
|
||||
{Mode: "order_product_customers", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key", "customer_code", "customer_name"}},
|
||||
{Mode: "order_market_details", Levels: []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
|
||||
}
|
||||
return defs, nil
|
||||
}
|
||||
|
||||
func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) {
|
||||
started := time.Now()
|
||||
stage := productPerformanceRefreshStage(req.Stage)
|
||||
@@ -689,6 +1285,7 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo
|
||||
}
|
||||
|
||||
var kpiRows int
|
||||
var snapshotRows int
|
||||
if shouldRun("kpi") {
|
||||
if err := runStage("kpi", func(tx *sql.Tx) error {
|
||||
log.Printf("[ProductPerformanceRefresh] kpi rebuild start")
|
||||
@@ -702,20 +1299,28 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo
|
||||
}); err != nil {
|
||||
return ProductPerformanceRefreshResult{}, err
|
||||
}
|
||||
log.Printf("[ProductPerformanceRefresh] report snapshots rebuild start")
|
||||
rows, err := RebuildProductPerformanceReportSnapshots(ctx, pg)
|
||||
if err != nil {
|
||||
return ProductPerformanceRefreshResult{}, err
|
||||
}
|
||||
snapshotRows = rows
|
||||
log.Printf("[ProductPerformanceRefresh] report snapshots rebuild done rows=%d elapsed=%s", snapshotRows, time.Since(started).Round(time.Second))
|
||||
} else {
|
||||
log.Printf("[ProductPerformanceRefresh] kpi skipped stage=%s", stage)
|
||||
}
|
||||
|
||||
log.Printf("[ProductPerformanceRefresh] refresh done total_elapsed=%s", time.Since(started).Round(time.Second))
|
||||
return ProductPerformanceRefreshResult{
|
||||
Mode: mode,
|
||||
Stage: stage,
|
||||
StartDate: req.StartDate.Format("2006-01-02"),
|
||||
EndDate: req.EndDate.Format("2006-01-02"),
|
||||
SalesRows: salesRows,
|
||||
StockRows: stockRows,
|
||||
KpiRows: kpiRows,
|
||||
DurationMS: time.Since(started).Milliseconds(),
|
||||
Mode: mode,
|
||||
Stage: stage,
|
||||
StartDate: req.StartDate.Format("2006-01-02"),
|
||||
EndDate: req.EndDate.Format("2006-01-02"),
|
||||
SalesRows: salesRows,
|
||||
StockRows: stockRows,
|
||||
KpiRows: kpiRows,
|
||||
SnapshotRows: snapshotRows,
|
||||
DurationMS: time.Since(started).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1168,6 +1773,14 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if !productPerformanceHasServerFilters(f) {
|
||||
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceRow](ctx, pg, productPerformanceSnapshotKey("products"), limit); err != nil {
|
||||
return nil, 0, err
|
||||
} else if ok {
|
||||
applyProductPerformanceProductRowScores(rows)
|
||||
return rows, len(rows), nil
|
||||
}
|
||||
}
|
||||
where, args := productPerformanceWhere(f)
|
||||
countQuery := `SELECT COUNT(*) FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + where
|
||||
var total int
|
||||
@@ -1183,11 +1796,15 @@ SELECT
|
||||
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total,
|
||||
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total,
|
||||
avg_daily_sales_90d, avg_daily_sales_180d, avg_daily_sales_365d, avg_daily_sales_total,
|
||||
avg_stock_90d, avg_stock_180d, avg_stock_365d, avg_stock_total,
|
||||
stock_days_90d, stock_days_180d, stock_days_365d, stock_days_total,
|
||||
stock_turnover_90d, stock_turnover_180d, stock_turnover_365d, stock_turnover_total,
|
||||
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,
|
||||
unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d,
|
||||
market_count_90d, market_count_180d, market_count_365d, market_count_total,
|
||||
customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total,
|
||||
sales_index_90d, sales_index_180d, sales_index_365d, sales_index_total,
|
||||
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')
|
||||
@@ -1209,11 +1826,15 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
||||
&r.SalesQty30, &r.SalesQty90, &r.SalesQty180, &r.SalesQty365, &r.SalesQty730, &r.SalesQtyTotal,
|
||||
&r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.SalesUSDTotal,
|
||||
&r.AvgDailySales90, &r.AvgDailySales180, &r.AvgDailySales365, &r.AvgDailySalesTotal,
|
||||
&r.AvgStock90, &r.AvgStock180, &r.AvgStock365, &r.AvgStockTotal,
|
||||
&r.StockDays90, &r.StockDays180, &r.StockDays365, &r.StockDaysTotal,
|
||||
&r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal,
|
||||
&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.UnitProfitCost90, &r.UnitProfitCost180, &r.UnitProfitBase90, &r.UnitProfitBase180,
|
||||
&r.MarketCount90, &r.MarketCount180, &r.MarketCount365, &r.MarketCountTotal,
|
||||
&r.CustomerCount90, &r.CustomerCount180, &r.CustomerCount365, &r.CustomerCountTotal,
|
||||
&r.SalesIndex90, &r.SalesIndex180, &r.SalesIndex365, &r.SalesIndexTotal,
|
||||
&r.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket,
|
||||
&r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -1230,13 +1851,125 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
||||
for i := range out {
|
||||
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
|
||||
}
|
||||
applyProductPerformanceProductRowScores(out)
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func applyProductPerformanceProductRowScores(rows []models.ProductPerformanceRow) {
|
||||
avg := productPerformanceProductRowAverages(rows)
|
||||
for i := range rows {
|
||||
rows[i].SalesIndex90 = productPerformanceRelativeIndex(rows[i].SalesUSD90, avg.salesUSD90)
|
||||
rows[i].SalesIndex180 = productPerformanceRelativeIndex(rows[i].SalesUSD180, avg.salesUSD180)
|
||||
rows[i].SalesIndex365 = productPerformanceRelativeIndex(rows[i].SalesUSD365, avg.salesUSD365)
|
||||
rows[i].SalesIndexTotal = productPerformanceRelativeIndex(rows[i].SalesUSDTotal, avg.salesUSDTotal)
|
||||
|
||||
margin365 := productPerformanceMarginFromSales(rows[i].SalesUSD365, rows[i].SalesQty365, rows[i].CostPriceUSD)
|
||||
marginTotal := productPerformanceMarginFromSales(rows[i].SalesUSDTotal, rows[i].SalesQtyTotal, rows[i].CostPriceUSD)
|
||||
|
||||
rows[i].PerformanceScore90 = productPerformanceProductScore(
|
||||
"90d",
|
||||
rows[i].SalesUSD90,
|
||||
rows[i].SalesIndex90,
|
||||
rows[i].GrossMargin90,
|
||||
rows[i].StockTurnover90,
|
||||
float64(rows[i].MarketCount90),
|
||||
float64(rows[i].CustomerCount90),
|
||||
)
|
||||
rows[i].PerformanceScore180 = productPerformanceProductScore(
|
||||
"180d",
|
||||
rows[i].SalesUSD180,
|
||||
rows[i].SalesIndex180,
|
||||
rows[i].GrossMargin180,
|
||||
rows[i].StockTurnover180,
|
||||
float64(rows[i].MarketCount180),
|
||||
float64(rows[i].CustomerCount180),
|
||||
)
|
||||
rows[i].PerformanceScore365 = productPerformanceProductScore(
|
||||
"365d",
|
||||
rows[i].SalesUSD365,
|
||||
rows[i].SalesIndex365,
|
||||
margin365,
|
||||
rows[i].StockTurnover365,
|
||||
float64(rows[i].MarketCount365),
|
||||
float64(rows[i].CustomerCount365),
|
||||
)
|
||||
rows[i].PerformanceScoreTotal = productPerformanceProductScore(
|
||||
"total",
|
||||
rows[i].SalesUSDTotal,
|
||||
rows[i].SalesIndexTotal,
|
||||
marginTotal,
|
||||
rows[i].StockTurnoverTotal,
|
||||
float64(rows[i].MarketCountTotal),
|
||||
float64(rows[i].CustomerCountTotal),
|
||||
productPerformanceProductRowTotalPeriodDays(rows[i]),
|
||||
)
|
||||
rows[i].PerformanceScore = rows[i].PerformanceScore90
|
||||
}
|
||||
}
|
||||
|
||||
type productPerformanceProductRowAverage struct {
|
||||
salesUSD90 float64
|
||||
salesUSD180 float64
|
||||
salesUSD365 float64
|
||||
salesUSDTotal float64
|
||||
}
|
||||
|
||||
func productPerformanceProductRowAverages(rows []models.ProductPerformanceRow) productPerformanceProductRowAverage {
|
||||
var sum90, count90, sum180, count180, sum365, count365, sumTotal, countTotal float64
|
||||
for _, row := range rows {
|
||||
if row.SalesUSD90 > 0 {
|
||||
sum90 += row.SalesUSD90
|
||||
count90++
|
||||
}
|
||||
if row.SalesUSD180 > 0 {
|
||||
sum180 += row.SalesUSD180
|
||||
count180++
|
||||
}
|
||||
if row.SalesUSD365 > 0 {
|
||||
sum365 += row.SalesUSD365
|
||||
count365++
|
||||
}
|
||||
if row.SalesUSDTotal > 0 {
|
||||
sumTotal += row.SalesUSDTotal
|
||||
countTotal++
|
||||
}
|
||||
}
|
||||
out := productPerformanceProductRowAverage{}
|
||||
if count90 > 0 {
|
||||
out.salesUSD90 = sum90 / count90
|
||||
}
|
||||
if count180 > 0 {
|
||||
out.salesUSD180 = sum180 / count180
|
||||
}
|
||||
if count365 > 0 {
|
||||
out.salesUSD365 = sum365 / count365
|
||||
}
|
||||
if countTotal > 0 {
|
||||
out.salesUSDTotal = sumTotal / countTotal
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func productPerformanceProductRowTotalPeriodDays(row models.ProductPerformanceRow) float64 {
|
||||
return productPerformancePeriodDays(map[string]any{"kpi_date": row.KpiDate}, "total")
|
||||
}
|
||||
|
||||
func productPerformanceMarginFromSales(salesUSD, salesQty, unitCost float64) float64 {
|
||||
if salesUSD <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (salesUSD - (salesQty * unitCost)) / salesUSD
|
||||
}
|
||||
|
||||
func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) {
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return models.ProductPerformanceSummary{}, err
|
||||
}
|
||||
if summary, ok, err := loadProductPerformanceSnapshotItem[models.ProductPerformanceSummary](ctx, pg, productPerformanceSnapshotKey("summary")); err != nil {
|
||||
return models.ProductPerformanceSummary{}, err
|
||||
} else if ok {
|
||||
return summary, nil
|
||||
}
|
||||
var s models.ProductPerformanceSummary
|
||||
err := pg.QueryRowContext(ctx, `
|
||||
WITH Latest AS (
|
||||
@@ -1247,7 +1980,7 @@ KPI AS (
|
||||
SELECT *
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT kpi_date FROM Latest)
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
),
|
||||
VariantStock AS (
|
||||
SELECT
|
||||
@@ -1274,7 +2007,7 @@ Sales90 AS (
|
||||
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)
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
)
|
||||
SELECT
|
||||
COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''),
|
||||
@@ -1307,6 +2040,12 @@ func ListProductPerformanceGeneral(ctx context.Context, pg *sql.DB, limit int) (
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceGeneralRow](ctx, pg, productPerformanceSnapshotKey("general"), limit); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
applyProductPerformanceGeneralRowScores(rows)
|
||||
return rows, nil
|
||||
}
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
WITH Bounds AS (
|
||||
SELECT
|
||||
@@ -1340,11 +2079,11 @@ SalesAgg AS (
|
||||
MAX(last_ref_number) AS last_ref_number,
|
||||
COALESCE(SUM(sales_qty),0) AS sales_qty_total,
|
||||
COALESCE(SUM(sales_usd),0) AS sales_usd_total,
|
||||
COALESCE(COUNT(DISTINCT NULLIF(customer_code, '-')),0)::integer AS customer_count_total,
|
||||
COALESCE(COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE COALESCE(sales_usd,0) > 0),0)::integer AS customer_count_total,
|
||||
COALESCE(SUM(invoice_count),0)::integer AS invoice_count_total
|
||||
FROM mk_product_performance_sales_daily, Bounds
|
||||
WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
GROUP BY product_code, color_code, yaka_kodu, market_key
|
||||
),
|
||||
StockAgg AS (
|
||||
@@ -1396,7 +2135,7 @@ Dim AS (
|
||||
1 AS src_rank
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
UNION ALL
|
||||
SELECT
|
||||
product_code,
|
||||
@@ -1412,7 +2151,7 @@ Dim AS (
|
||||
MAX(urun_alt_grubu) AS urun_alt_grubu,
|
||||
2 AS src_rank
|
||||
FROM mk_product_performance_sales_daily
|
||||
WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
GROUP BY product_code, color_code, yaka_kodu
|
||||
) x
|
||||
ORDER BY product_code, color_code, yaka_kodu, src_rank
|
||||
@@ -1444,7 +2183,7 @@ StockOnly AS (
|
||||
AND d.color_code = s.color_code
|
||||
AND d.yaka_kodu = s.yaka_kodu
|
||||
WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate)
|
||||
AND upper(btrim(COALESCE(d.urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(d.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM SalesAgg a
|
||||
@@ -1464,11 +2203,11 @@ Spread AS (
|
||||
product_code,
|
||||
color_code,
|
||||
yaka_kodu,
|
||||
COUNT(DISTINCT NULLIF(market_key, 'STOK'))::integer AS market_count_total,
|
||||
COUNT(DISTINCT NULLIF(customer_code, '-'))::integer AS customer_count_total_all
|
||||
COUNT(DISTINCT NULLIF(market_key, 'STOK')) FILTER (WHERE COALESCE(sales_usd,0) > 0)::integer AS market_count_total,
|
||||
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE COALESCE(sales_usd,0) > 0)::integer AS customer_count_total_all
|
||||
FROM mk_product_performance_sales_daily, Bounds
|
||||
WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
GROUP BY product_code, color_code, yaka_kodu
|
||||
),
|
||||
Scored AS (
|
||||
@@ -1502,8 +2241,8 @@ Scored AS (
|
||||
CASE WHEN b.sales_usd_total <= 0 THEN 0 ELSE (b.sales_usd_total - (b.sales_qty_total * COALESCE(pd.cost_price_usd,0))) / NULLIF(b.sales_usd_total,0) END AS gross_margin_total,
|
||||
CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE (b.sales_usd_total / NULLIF(b.sales_qty_total,0)) - COALESCE(pd.cost_price_usd,0) END AS unit_profit_cost_total,
|
||||
CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE (b.sales_usd_total / NULLIF(b.sales_qty_total,0)) - COALESCE(pd.base_price_usd,0) END AS unit_profit_base_total,
|
||||
COALESCE(sp.market_count_total,0) AS market_count_total,
|
||||
COALESCE(sp.customer_count_total_all, b.customer_count_total) AS customer_count_total,
|
||||
CASE WHEN b.market_key <> 'STOK' AND b.sales_usd_total > 0 THEN 1 ELSE 0 END AS market_count_total,
|
||||
b.customer_count_total AS customer_count_total,
|
||||
b.invoice_count_total,
|
||||
CASE
|
||||
WHEN AVG(b.sales_qty_total) OVER (PARTITION BY b.market_key, b.kategori, b.urun_ana_grubu) <= 0 THEN 0
|
||||
@@ -1552,7 +2291,7 @@ SELECT
|
||||
WHEN sales_qty_total > 0 AND stock_qty <= 0 AND sales_index_total >= 1 THEN 'STOKSUZ_TALEP'
|
||||
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'STOK_RISKI'
|
||||
WHEN gross_margin_total < 0 THEN 'FIYAT_BASKISI'
|
||||
WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.25 THEN 'YILDIZ_URUN'
|
||||
WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.45 THEN 'YILDIZ_URUN'
|
||||
WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'FIYAT_FIRSATI'
|
||||
ELSE 'TAKIP'
|
||||
END AS performance_bucket,
|
||||
@@ -1560,7 +2299,7 @@ SELECT
|
||||
WHEN sales_qty_total > 0 AND stock_qty <= 0 THEN 'Talep var, stok yok. Uretim/satin alma onceligi ver.'
|
||||
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'Satis yok, stok maliyeti tasiyor. Piyasa/fiyat aksiyonu gerekli.'
|
||||
WHEN gross_margin_total < 0 THEN 'Ciplak maliyet altinda satis var. Fiyat veya maliyet kontrol edilmeli.'
|
||||
WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.25 THEN 'Genel donemde guclu urun. Stok ve fiyat korunmali.'
|
||||
WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.45 THEN 'Genel donemde guclu urun. Stok ve fiyat korunmali.'
|
||||
WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'Karli ama yavas. Dogru piyasada satis firsati var.'
|
||||
ELSE 'Izleme ve piyasa bazli aksiyon.'
|
||||
END AS recommendation,
|
||||
@@ -1600,9 +2339,28 @@ LIMIT $1
|
||||
for i := range out {
|
||||
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
|
||||
}
|
||||
applyProductPerformanceGeneralRowScores(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyProductPerformanceGeneralRowScores(rows []models.ProductPerformanceGeneralRow) {
|
||||
for i := range rows {
|
||||
turnover := 0.0
|
||||
if rows[i].StockQty > 0 {
|
||||
turnover = rows[i].SalesQtyTotal / rows[i].StockQty
|
||||
}
|
||||
rows[i].PerformanceScore = productPerformanceProductScore(
|
||||
"total",
|
||||
rows[i].SalesUSDTotal,
|
||||
rows[i].SalesIndexTotal,
|
||||
rows[i].GrossMarginTotal,
|
||||
turnover,
|
||||
float64(rows[i].MarketCountTotal),
|
||||
float64(rows[i].CustomerCountTotal),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderAnalysisRow, error) {
|
||||
if db.MssqlDB == nil {
|
||||
return nil, fmt.Errorf("mssql db nil")
|
||||
@@ -1615,6 +2373,11 @@ func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderAnalysisRow](ctx, pg, productPerformanceSnapshotKey("orders"), limit); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
rows, err := db.MssqlDB.QueryContext(ctx, `
|
||||
WITH OpenOrderLines AS (
|
||||
@@ -1917,6 +2680,11 @@ func ListProductPerformanceOrderGroups(ctx context.Context, pg *sql.DB, breakdow
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderGroupRow](ctx, pg, productPerformanceSnapshotKey("order-groups", mode), limit); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
rows, err := db.MssqlDB.QueryContext(ctx, `
|
||||
WITH OpenOrderLines AS (
|
||||
@@ -2153,6 +2921,11 @@ func ListProductPerformanceOrderProductCustomers(ctx context.Context, pg *sql.DB
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderProductCustomerRow](ctx, pg, productPerformanceSnapshotKey("order-product-customers"), limit); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return rows, nil
|
||||
}
|
||||
rows, err := db.MssqlDB.QueryContext(ctx, `
|
||||
WITH OpenOrderLines AS (
|
||||
SELECT
|
||||
@@ -2291,6 +3064,11 @@ func ListProductPerformanceOrderMarketDetails(ctx context.Context, pg *sql.DB, l
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderMarketDetailRow](ctx, pg, productPerformanceSnapshotKey("order-market-details"), limit); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return rows, nil
|
||||
}
|
||||
rows, err := db.MssqlDB.QueryContext(ctx, `
|
||||
WITH OpenOrderLines AS (
|
||||
SELECT
|
||||
@@ -2414,6 +3192,11 @@ func ListProductPerformanceMarkets(ctx context.Context, pg *sql.DB, limit int) (
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceMarketRow](ctx, pg, productPerformanceSnapshotKey("markets"), limit); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return rows, nil
|
||||
}
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT
|
||||
market_key,
|
||||
@@ -2437,7 +3220,7 @@ SELECT
|
||||
COALESCE(AVG(NULLIF(stock_days_90d,0)),0) AS avg_stock_days_90d
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
GROUP BY market_key
|
||||
ORDER BY risk_stock_cost_value_usd DESC, stock_cost_value_usd DESC, sales_usd_90d DESC
|
||||
LIMIT $1
|
||||
@@ -2472,6 +3255,11 @@ func ListProductPerformanceCountries(ctx context.Context, pg *sql.DB, limit int)
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceCountryRow](ctx, pg, productPerformanceSnapshotKey("countries"), limit); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return rows, nil
|
||||
}
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT
|
||||
customer_country,
|
||||
@@ -2487,13 +3275,13 @@ SELECT
|
||||
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
||||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
||||
END AS avg_price_usd_90d,
|
||||
COALESCE(SUM(customer_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS customer_count_90d,
|
||||
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(sales_usd,0) > 0)::integer AS customer_count_90d,
|
||||
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d,
|
||||
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_qty_365d,
|
||||
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d
|
||||
FROM mk_product_performance_sales_daily
|
||||
WHERE sales_date >= current_date - INTERVAL '364 days'
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
GROUP BY customer_country, customer_segment, market_key
|
||||
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
|
||||
LIMIT $1
|
||||
@@ -2549,6 +3337,11 @@ func ListProductPerformanceCustomers(ctx context.Context, pg *sql.DB, breakdown
|
||||
selectMarket = "market_key"
|
||||
groupCols = append(groupCols, "market_key")
|
||||
}
|
||||
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceCustomerRow](ctx, pg, productPerformanceSnapshotKey("customers", mode), limit); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
@@ -2573,7 +3366,7 @@ SELECT
|
||||
FROM mk_product_performance_sales_daily
|
||||
WHERE sales_date >= current_date - INTERVAL '364 days'
|
||||
AND COALESCE(customer_code,'') <> ''
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
GROUP BY %s
|
||||
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
|
||||
LIMIT $1
|
||||
@@ -2616,7 +3409,7 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
|
||||
"item_description": "MAX(s.item_description)",
|
||||
"kategori": "COALESCE(MAX(s.kategori),'')",
|
||||
"askili_yan": "COALESCE(MAX(CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END),'')",
|
||||
"urun_ilk_grubu": "COALESCE(MAX(CASE WHEN btrim(COALESCE(s.urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE s.urun_ilk_grubu END),'')",
|
||||
"urun_ilk_grubu": "COALESCE(MAX(" + productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu") + "),'')",
|
||||
"urun_ana_grubu": "COALESCE(MAX(s.urun_ana_grubu),'')",
|
||||
"urun_alt_grubu": "MAX(s.urun_alt_grubu)",
|
||||
"market_key": "s.market_key",
|
||||
@@ -2643,7 +3436,7 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
|
||||
selected["market_key"] = true
|
||||
selected["customer_code"] = true
|
||||
selected["customer_name"] = true
|
||||
groupCols = []string{"CASE WHEN btrim(COALESCE(s.urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE s.urun_ilk_grubu END", "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
|
||||
groupCols = []string{productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu"), "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
|
||||
case "product_country_segment_market_customer":
|
||||
selected["product_code"] = true
|
||||
selected["item_description"] = true
|
||||
@@ -2706,7 +3499,14 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
|
||||
selected["market_key"] = true
|
||||
selected["customer_code"] = true
|
||||
selected["customer_name"] = true
|
||||
groupCols = []string{"CASE WHEN btrim(COALESCE(s.urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE s.urun_ilk_grubu END", "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
|
||||
groupCols = []string{productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu"), "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
|
||||
}
|
||||
|
||||
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceSalesBreakdownRow](ctx, pg, productPerformanceSnapshotKey("sales-breakdown", mode), limit); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
applyProductPerformanceSalesBreakdownScores(rows)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
selectExpr := func(name string) string {
|
||||
@@ -2726,14 +3526,14 @@ WITH LatestKPI AS (
|
||||
CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan,
|
||||
CASE
|
||||
WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN ''
|
||||
WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN ''
|
||||
WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN ''
|
||||
ELSE COALESCE(urun_ilk_grubu,'')
|
||||
END AS urun_ilk_grubu,
|
||||
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
|
||||
urun_alt_grubu
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
|
||||
),
|
||||
StockAgg AS (
|
||||
@@ -2786,6 +3586,19 @@ StockTotalStart AS (
|
||||
WHERE stock_date <= DATE '2022-01-01'
|
||||
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
|
||||
),
|
||||
FirstSaleByProduct AS (
|
||||
SELECT
|
||||
product_code,
|
||||
color_code,
|
||||
yaka_kodu,
|
||||
MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days') AS first_sale_date_90d,
|
||||
MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days') AS first_sale_date_180d,
|
||||
MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days') AS first_sale_date_365d,
|
||||
MIN(sales_date) FILTER (WHERE sales_date >= DATE '2022-01-01') AS first_sale_date_total
|
||||
FROM mk_product_performance_sales_daily
|
||||
WHERE sales_date >= DATE '2022-01-01'
|
||||
GROUP BY product_code, color_code, yaka_kodu
|
||||
),
|
||||
Agg AS (
|
||||
SELECT
|
||||
$2::text AS breakdown,
|
||||
@@ -2804,12 +3617,20 @@ Agg AS (
|
||||
%s,
|
||||
%s,
|
||||
COUNT(DISTINCT product_code) AS product_count,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days')::integer AS product_group_count_90d,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days')::integer AS product_group_count_180d,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days')::integer AS product_group_count_365d,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= DATE '2022-01-01')::integer AS product_group_count_total,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(sales_usd,0) > 0)::integer AS market_count_90d,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days' AND COALESCE(sales_usd,0) > 0)::integer AS market_count_180d,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days' AND COALESCE(sales_usd,0) > 0)::integer AS market_count_365d,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(sales_usd,0) > 0)::integer AS market_count_total,
|
||||
COALESCE(MAX(stock_qty),0) AS stock_qty,
|
||||
COALESCE(MAX(avg_stock_90d),0) AS avg_stock_90d,
|
||||
COALESCE(MAX(avg_stock_180d),0) AS avg_stock_180d,
|
||||
COALESCE(MAX(avg_stock_365d),0) AS avg_stock_365d,
|
||||
COALESCE(MAX(avg_stock_total),0) AS avg_stock_total,
|
||||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_90d,
|
||||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(customer_code,'') <> '' AND COALESCE(sales_usd,0) > 0)::integer AS customer_count_90d,
|
||||
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d,
|
||||
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d,
|
||||
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d,
|
||||
@@ -2825,7 +3646,7 @@ Agg AS (
|
||||
ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
||||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
||||
END AS cost_price_usd_90d,
|
||||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_180d,
|
||||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days' AND COALESCE(customer_code,'') <> '' AND COALESCE(sales_usd,0) > 0)::integer AS customer_count_180d,
|
||||
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)::integer AS invoice_count_180d,
|
||||
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) AS sales_qty_180d,
|
||||
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) AS sales_usd_180d,
|
||||
@@ -2843,7 +3664,7 @@ Agg AS (
|
||||
END AS cost_price_usd_180d,
|
||||
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_qty_365d,
|
||||
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_usd_365d,
|
||||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_365d,
|
||||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days' AND COALESCE(customer_code,'') <> '' AND COALESCE(sales_usd,0) > 0)::integer AS customer_count_365d,
|
||||
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)::integer AS invoice_count_365d,
|
||||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0
|
||||
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||||
@@ -2857,7 +3678,7 @@ Agg AS (
|
||||
ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||||
END AS cost_price_usd_365d,
|
||||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_total,
|
||||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(customer_code,'') <> '' AND COALESCE(sales_usd,0) > 0)::integer AS customer_count_total,
|
||||
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)::integer AS invoice_count_total,
|
||||
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) AS sales_qty_total,
|
||||
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) AS sales_usd_total,
|
||||
@@ -2880,21 +3701,58 @@ Agg AS (
|
||||
COALESCE(pd.base_price_usd,0) AS base_price_usd,
|
||||
COALESCE(pd.cost_price_usd,0) AS cost_price_usd,
|
||||
COALESCE(st.stock_qty,0) AS stock_qty,
|
||||
(COALESCE(s90.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_90d,
|
||||
(COALESCE(s180.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_180d,
|
||||
(COALESCE(s365.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_365d,
|
||||
(COALESCE(stotal.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_total
|
||||
(COALESCE(s90.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_90d,
|
||||
(COALESCE(s180.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_180d,
|
||||
(COALESCE(s365.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_365d,
|
||||
(COALESCE(stotal.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_total
|
||||
FROM mk_product_performance_sales_daily s
|
||||
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code
|
||||
LEFT JOIN LatestKPI k ON k.product_code = s.product_code AND k.color_code = s.color_code AND k.yaka_kodu = s.yaka_kodu
|
||||
LEFT JOIN FirstSaleByProduct fs ON fs.product_code = s.product_code AND fs.color_code = s.color_code AND fs.yaka_kodu = s.yaka_kodu
|
||||
LEFT JOIN StockAgg st ON st.product_code = s.product_code AND st.color_code = s.color_code AND st.yaka_kodu = s.yaka_kodu
|
||||
LEFT JOIN Stock90Start s90 ON s90.product_code = s.product_code AND s90.color_code = s.color_code AND s90.yaka_kodu = s.yaka_kodu
|
||||
LEFT JOIN Stock180Start s180 ON s180.product_code = s.product_code AND s180.color_code = s.color_code AND s180.yaka_kodu = s.yaka_kodu
|
||||
LEFT JOIN Stock365Start s365 ON s365.product_code = s.product_code AND s365.color_code = s.color_code AND s365.yaka_kodu = s.yaka_kodu
|
||||
LEFT JOIN StockTotalStart stotal ON stotal.product_code = s.product_code AND stotal.color_code = s.color_code AND stotal.yaka_kodu = s.yaka_kodu
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT x.stock_qty
|
||||
FROM mk_product_performance_stock_daily x
|
||||
WHERE x.product_code = s.product_code
|
||||
AND x.color_code = s.color_code
|
||||
AND x.yaka_kodu = s.yaka_kodu
|
||||
AND x.stock_date <= COALESCE(fs.first_sale_date_90d, current_date - INTERVAL '89 days')
|
||||
ORDER BY x.stock_date DESC
|
||||
LIMIT 1
|
||||
) s90 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT x.stock_qty
|
||||
FROM mk_product_performance_stock_daily x
|
||||
WHERE x.product_code = s.product_code
|
||||
AND x.color_code = s.color_code
|
||||
AND x.yaka_kodu = s.yaka_kodu
|
||||
AND x.stock_date <= COALESCE(fs.first_sale_date_180d, current_date - INTERVAL '179 days')
|
||||
ORDER BY x.stock_date DESC
|
||||
LIMIT 1
|
||||
) s180 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT x.stock_qty
|
||||
FROM mk_product_performance_stock_daily x
|
||||
WHERE x.product_code = s.product_code
|
||||
AND x.color_code = s.color_code
|
||||
AND x.yaka_kodu = s.yaka_kodu
|
||||
AND x.stock_date <= COALESCE(fs.first_sale_date_365d, current_date - INTERVAL '359 days')
|
||||
ORDER BY x.stock_date DESC
|
||||
LIMIT 1
|
||||
) s365 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT x.stock_qty
|
||||
FROM mk_product_performance_stock_daily x
|
||||
WHERE x.product_code = s.product_code
|
||||
AND x.color_code = s.color_code
|
||||
AND x.yaka_kodu = s.yaka_kodu
|
||||
AND x.stock_date <= COALESCE(fs.first_sale_date_total, DATE '2022-01-01')
|
||||
ORDER BY x.stock_date DESC
|
||||
LIMIT 1
|
||||
) stotal ON TRUE
|
||||
) s
|
||||
WHERE sales_date >= DATE '2022-01-01'
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
GROUP BY %s
|
||||
),
|
||||
Enriched AS (
|
||||
@@ -2944,6 +3802,14 @@ SELECT
|
||||
customer_code,
|
||||
customer_name,
|
||||
product_count,
|
||||
product_group_count_90d,
|
||||
product_group_count_180d,
|
||||
product_group_count_365d,
|
||||
product_group_count_total,
|
||||
market_count_90d,
|
||||
market_count_180d,
|
||||
market_count_365d,
|
||||
market_count_total,
|
||||
stock_qty,
|
||||
customer_count_90d,
|
||||
invoice_count_90d,
|
||||
@@ -2989,55 +3855,55 @@ SELECT
|
||||
gross_profit_cost_usd_total,
|
||||
gross_margin_base_total,
|
||||
gross_margin_cost_total,
|
||||
CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END AS stock_turnover_90d,
|
||||
CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) ELSE 0 END AS stock_turnover_180d,
|
||||
avg_stock_90d,
|
||||
avg_stock_180d,
|
||||
avg_stock_365d,
|
||||
avg_stock_total,
|
||||
CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END AS stock_turnover_90d,
|
||||
CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END AS stock_turnover_180d,
|
||||
CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END AS stock_turnover_365d,
|
||||
CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) ELSE 0 END AS stock_turnover_total,
|
||||
CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, (current_date - DATE '2022-01-01') + 1) ELSE 0 END AS stock_turnover_total,
|
||||
has_cost,
|
||||
sales_index_90d,
|
||||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||||
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE
|
||||
ROUND((
|
||||
LEAST(35, sales_usd_90d / 1000)
|
||||
+ LEAST(25, GREATEST(gross_margin_cost_90d,0) * 55)
|
||||
+ LEAST(20, invoice_count_90d * 2)
|
||||
+ LEAST(10, sales_qty_90d / 10)
|
||||
+ LEAST(10, product_count)
|
||||
LEAST(35, sales_usd_90d / 25000 * 35)
|
||||
+ LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.45 * 30)
|
||||
+ LEAST(20, product_group_count_90d / 8.0 * 20)
|
||||
+ LEAST(15, sales_qty_90d / 500 * 15)
|
||||
)::numeric, 4)
|
||||
END AS customer_score_90d,
|
||||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||||
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_180d <= 0 THEN 1 ELSE
|
||||
ROUND((
|
||||
LEAST(35, sales_usd_180d / 1000)
|
||||
+ LEAST(25, GREATEST(gross_margin_cost_180d,0) * 55)
|
||||
+ LEAST(20, invoice_count_180d * 2)
|
||||
+ LEAST(10, sales_qty_180d / 10)
|
||||
+ LEAST(10, product_count)
|
||||
LEAST(35, sales_usd_180d / 50000 * 35)
|
||||
+ LEAST(30, GREATEST(gross_margin_cost_180d,0) / 0.45 * 30)
|
||||
+ LEAST(20, product_group_count_180d / 8.0 * 20)
|
||||
+ LEAST(15, sales_qty_180d / 1000 * 15)
|
||||
)::numeric, 4)
|
||||
END AS customer_score_180d,
|
||||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||||
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_365d <= 0 THEN 1 ELSE
|
||||
ROUND((
|
||||
LEAST(35, sales_usd_365d / 1000)
|
||||
+ LEAST(25, GREATEST(gross_margin_cost_365d,0) * 55)
|
||||
+ LEAST(20, invoice_count_365d * 2)
|
||||
+ LEAST(10, sales_qty_365d / 10)
|
||||
+ LEAST(10, product_count)
|
||||
LEAST(35, sales_usd_365d / 100000 * 35)
|
||||
+ LEAST(30, GREATEST(gross_margin_cost_365d,0) / 0.45 * 30)
|
||||
+ LEAST(20, product_group_count_365d / 8.0 * 20)
|
||||
+ LEAST(15, sales_qty_365d / 2000 * 15)
|
||||
)::numeric, 4)
|
||||
END AS customer_score_365d,
|
||||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||||
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_total <= 0 THEN 1 ELSE
|
||||
ROUND((
|
||||
LEAST(35, sales_usd_total / 1000)
|
||||
+ LEAST(25, GREATEST(gross_margin_cost_total,0) * 55)
|
||||
+ LEAST(20, invoice_count_total * 2)
|
||||
+ LEAST(10, sales_qty_total / 10)
|
||||
+ LEAST(10, product_count)
|
||||
LEAST(35, sales_usd_total / 300000 * 35)
|
||||
+ LEAST(30, GREATEST(gross_margin_cost_total,0) / 0.45 * 30)
|
||||
+ LEAST(20, product_group_count_total / 8.0 * 20)
|
||||
+ LEAST(15, sales_qty_total / 6000 * 15)
|
||||
)::numeric, 4)
|
||||
END AS customer_score_total,
|
||||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||||
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE
|
||||
ROUND(
|
||||
LEAST(35, sales_index_90d * 18)
|
||||
+ LEAST(30, GREATEST(gross_margin_cost_90d,0) * 60)
|
||||
+ LEAST(15, invoice_count_90d * 1.5)
|
||||
+ LEAST(10, sales_qty_90d / 10)
|
||||
+ LEAST(10, (CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END) * 5),
|
||||
LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.45 * 30)
|
||||
+ LEAST(20, (CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END) / 4.0 * 20)
|
||||
+ LEAST(20, sales_usd_90d / 25000 * 20)
|
||||
+ LEAST(5, market_count_90d / 3.0 * 5)
|
||||
+ LEAST(25, customer_count_90d / 8.0 * 25),
|
||||
4
|
||||
)
|
||||
END AS performance_score,
|
||||
@@ -3050,8 +3916,8 @@ SELECT
|
||||
ELSE 'TAKIP'
|
||||
END AS performance_bucket,
|
||||
CASE
|
||||
WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'Güçlü satış kırılımı'
|
||||
WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'Son 90 gün zayıf, kontrol et'
|
||||
WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'Guclu satis kirilimi'
|
||||
WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'Son 90 gun zayif, kontrol et'
|
||||
ELSE 'Takip'
|
||||
END AS recommendation,
|
||||
last_sale_date
|
||||
@@ -3089,7 +3955,9 @@ LIMIT $1
|
||||
&r.Breakdown, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
|
||||
&r.Kategori, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu,
|
||||
&r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName,
|
||||
&r.ProductCount, &r.StockQty, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90,
|
||||
&r.ProductCount, &r.ProductGroupCount90, &r.ProductGroupCount180, &r.ProductGroupCount365, &r.ProductGroupCountTotal,
|
||||
&r.MarketCount90, &r.MarketCount180, &r.MarketCount365, &r.MarketCountTotal,
|
||||
&r.StockQty, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90,
|
||||
&r.AvgPriceUSD90, &r.BasePriceUSD90, &r.CostPriceUSD90, &r.GrossProfitBase90,
|
||||
&r.GrossProfitCost90, &r.GrossMarginBase90, &r.GrossMarginCost90, &r.CustomerCount180,
|
||||
&r.InvoiceCount180, &r.SalesQty180, &r.SalesUSD180, &r.AvgPriceUSD180, &r.BasePriceUSD180,
|
||||
@@ -3099,6 +3967,7 @@ LIMIT $1
|
||||
&r.GrossMarginCost365, &r.CustomerCount365, &r.InvoiceCount365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal,
|
||||
&r.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal,
|
||||
&r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal,
|
||||
&r.AvgStock90, &r.AvgStock180, &r.AvgStock365, &r.AvgStockTotal,
|
||||
&r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.HasCost,
|
||||
&r.SalesIndex90, &r.CustomerScore90, &r.CustomerScore180, &r.CustomerScore365, &r.CustomerScoreTotal, &r.PerformanceScore,
|
||||
&r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate,
|
||||
@@ -3115,18 +3984,76 @@ LIMIT $1
|
||||
for i := range out {
|
||||
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
|
||||
}
|
||||
applyProductPerformanceSalesBreakdownScores(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyProductPerformanceSalesBreakdownScores(rows []models.ProductPerformanceSalesBreakdownRow) {
|
||||
avg90 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD90 })
|
||||
avg180 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD180 })
|
||||
avg365 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD365 })
|
||||
avgTotal := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSDTotal })
|
||||
|
||||
for i := range rows {
|
||||
r := &rows[i]
|
||||
r.SalesIndex90 = productPerformanceRelativeIndex(r.SalesUSD90, avg90)
|
||||
r.SalesIndex180 = productPerformanceRelativeIndex(r.SalesUSD180, avg180)
|
||||
r.SalesIndex365 = productPerformanceRelativeIndex(r.SalesUSD365, avg365)
|
||||
r.SalesIndexTotal = productPerformanceRelativeIndex(r.SalesUSDTotal, avgTotal)
|
||||
|
||||
r.CustomerScore90 = productPerformanceCustomerScore("90d", r.SalesUSD90, r.GrossMarginCost90, float64(r.ProductGroupCount90), r.SalesQty90)
|
||||
r.CustomerScore180 = productPerformanceCustomerScore("180d", r.SalesUSD180, r.GrossMarginCost180, float64(r.ProductGroupCount180), r.SalesQty180)
|
||||
r.CustomerScore365 = productPerformanceCustomerScore("365d", r.SalesUSD365, r.GrossMarginCost365, float64(r.ProductGroupCount365), r.SalesQty365)
|
||||
r.CustomerScoreTotal = productPerformanceCustomerScore("total", r.SalesUSDTotal, r.GrossMarginCostTotal, float64(r.ProductGroupCountTotal), r.SalesQtyTotal)
|
||||
|
||||
r.PerformanceScore90 = productPerformanceProductScore("90d", r.SalesUSD90, r.SalesIndex90, r.GrossMarginCost90, r.StockTurnover90, float64(r.MarketCount90), float64(r.CustomerCount90))
|
||||
r.PerformanceScore180 = productPerformanceProductScore("180d", r.SalesUSD180, r.SalesIndex180, r.GrossMarginCost180, r.StockTurnover180, float64(r.MarketCount180), float64(r.CustomerCount180))
|
||||
r.PerformanceScore365 = productPerformanceProductScore("365d", r.SalesUSD365, r.SalesIndex365, r.GrossMarginCost365, r.StockTurnover365, float64(r.MarketCount365), float64(r.CustomerCount365))
|
||||
r.PerformanceScoreTotal = productPerformanceProductScore("total", r.SalesUSDTotal, r.SalesIndexTotal, r.GrossMarginCostTotal, r.StockTurnoverTotal, float64(r.MarketCountTotal), float64(r.CustomerCountTotal))
|
||||
r.PerformanceScore = r.PerformanceScore90
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceSalesBreakdownAverage(rows []models.ProductPerformanceSalesBreakdownRow, value func(models.ProductPerformanceSalesBreakdownRow) float64) float64 {
|
||||
var sum, count float64
|
||||
for _, row := range rows {
|
||||
v := value(row)
|
||||
if v <= 0 {
|
||||
continue
|
||||
}
|
||||
sum += v
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return 0
|
||||
}
|
||||
return sum / count
|
||||
}
|
||||
|
||||
type ProductPerformanceGroupedRequest struct {
|
||||
Mode string
|
||||
GroupLevels []string
|
||||
ExpandedKeys map[string]bool
|
||||
Limit int
|
||||
MainGroup string
|
||||
Mode string
|
||||
GroupLevels []string
|
||||
ExpandedKeys map[string]bool
|
||||
ExpandThroughLevel int
|
||||
Limit int
|
||||
MainGroup string
|
||||
Filters map[string][]string
|
||||
SortBy string
|
||||
Descending bool
|
||||
}
|
||||
|
||||
type ProductPerformanceGroupedFilterOptionsRequest struct {
|
||||
Mode string
|
||||
GroupLevels []string
|
||||
MainGroup string
|
||||
Fields []string
|
||||
Limit int
|
||||
}
|
||||
|
||||
func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) {
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 50000 {
|
||||
req.Limit = 50000
|
||||
}
|
||||
@@ -3134,8 +4061,11 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
|
||||
if len(levels) == 0 {
|
||||
levels = defaultProductPerformanceGroupLevels(req.Mode)
|
||||
}
|
||||
if req.ExpandThroughLevel > len(levels)-2 {
|
||||
req.ExpandThroughLevel = len(levels) - 2
|
||||
}
|
||||
|
||||
if out, ok, err := listProductPerformanceGroupedSQL(ctx, pg, req, levels); ok || err != nil {
|
||||
if out, ok, err := loadProductPerformancePreparedGroupedRows(ctx, pg, req, levels); ok || err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -3143,14 +4073,336 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceRows = filterProductPerformanceGroupedRows(sourceRows, productPerformanceGroupedEffectiveFilters(req))
|
||||
out := make([]map[string]any, 0, len(sourceRows))
|
||||
appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys)
|
||||
appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys, req.ExpandThroughLevel)
|
||||
out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending)
|
||||
if len(out) > 0 || !productPerformanceLiveFallbackEnabled() {
|
||||
return out, nil
|
||||
}
|
||||
if out, ok, err := listProductPerformanceGroupedSQL(ctx, pg, req, levels); ok || err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ListProductPerformanceGroupedFilterOptions(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedFilterOptionsRequest) (map[string][]string, error) {
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 50000 {
|
||||
req.Limit = 50000
|
||||
}
|
||||
levels := sanitizeProductPerformanceGroupLevels(req.GroupLevels)
|
||||
if len(levels) == 0 {
|
||||
levels = defaultProductPerformanceGroupLevels(req.Mode)
|
||||
}
|
||||
reportKey := productPerformanceGroupedSnapshotReportKey(req.Mode, levels, req.MainGroup)
|
||||
if strings.TrimSpace(reportKey) == "" {
|
||||
return map[string][]string{}, nil
|
||||
}
|
||||
levelSet := map[string]bool{}
|
||||
for _, level := range levels {
|
||||
levelSet[level] = true
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, field := range req.Fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if !productPerformanceGroupedFilterOptionFieldAllowed(field) {
|
||||
continue
|
||||
}
|
||||
values, err := productPerformanceGroupedSnapshotFilterOptions(ctx, pg, reportKey, field, levelSet, strings.TrimSpace(req.MainGroup), req.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[field] = values
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func productPerformanceGroupedFilterOptionFieldAllowed(field string) bool {
|
||||
switch field {
|
||||
case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu",
|
||||
"product_code", "color_yaka", "market_key", "country", "customer_segment",
|
||||
"customer_code", "customer_name", "performance_bucket":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceGroupedSnapshotFilterOptions(ctx context.Context, pg *sql.DB, reportKey, field string, levelSet map[string]bool, mainGroup string, limit int) ([]string, error) {
|
||||
if field == "urun_ana_grubu" && strings.Contains(reportKey, ":product_detail:") && strings.TrimSpace(mainGroup) != "" {
|
||||
return []string{strings.TrimSpace(mainGroup)}, nil
|
||||
}
|
||||
if field == "performance_bucket" {
|
||||
return productPerformanceGroupedSnapshotPayloadOptions(ctx, pg, reportKey, "performance_bucket", limit)
|
||||
}
|
||||
if !levelSet[field] {
|
||||
return []string{}, nil
|
||||
}
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT DISTINCT btrim(group_value) AS value
|
||||
FROM mk_product_performance_grouped_snapshot
|
||||
WHERE report_key = $1
|
||||
AND group_field = $2
|
||||
AND btrim(group_value) <> ''
|
||||
ORDER BY value
|
||||
LIMIT $3
|
||||
`, reportKey, field, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProductPerformanceGroupedFilterOptionRows(rows)
|
||||
}
|
||||
|
||||
func productPerformanceGroupedSnapshotPayloadOptions(ctx context.Context, pg *sql.DB, reportKey, field string, limit int) ([]string, error) {
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT DISTINCT btrim(payload ->> $2) AS value
|
||||
FROM mk_product_performance_grouped_snapshot
|
||||
WHERE report_key = $1
|
||||
AND btrim(payload ->> $2) <> ''
|
||||
ORDER BY value
|
||||
LIMIT $3
|
||||
`, reportKey, field, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProductPerformanceGroupedFilterOptionRows(rows)
|
||||
}
|
||||
|
||||
func scanProductPerformanceGroupedFilterOptionRows(rows *sql.Rows) ([]string, error) {
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err := rows.Scan(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadProductPerformancePreparedGroupedRows(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) {
|
||||
reportKey := productPerformanceGroupedSnapshotReportKey(req.Mode, levels, req.MainGroup)
|
||||
if strings.TrimSpace(reportKey) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
effectiveFilters := productPerformancePreparedGroupedEffectiveFilters(req)
|
||||
hasFilters := len(effectiveFilters) > 0
|
||||
if hasFilters {
|
||||
return nil, false, nil
|
||||
}
|
||||
hasManualExpansion := len(req.ExpandedKeys) > 0
|
||||
query := `
|
||||
SELECT payload
|
||||
FROM mk_product_performance_grouped_snapshot
|
||||
WHERE report_key = $1
|
||||
`
|
||||
args := []any{reportKey}
|
||||
if !hasFilters && !hasManualExpansion {
|
||||
maxVisibleLevel := req.ExpandThroughLevel + 1
|
||||
if maxVisibleLevel < 0 {
|
||||
maxVisibleLevel = 0
|
||||
}
|
||||
args = append(args, maxVisibleLevel)
|
||||
query += fmt.Sprintf(" AND group_level <= $%d\n", len(args))
|
||||
}
|
||||
query += "ORDER BY row_order"
|
||||
if !hasFilters && !hasManualExpansion {
|
||||
args = append(args, req.Limit)
|
||||
query += fmt.Sprintf("\nLIMIT $%d", len(args))
|
||||
}
|
||||
|
||||
rows, err := pg.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]map[string]any, 0, minInt(req.Limit, 50000))
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal(raw, &item); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if item == nil {
|
||||
item = map[string]any{}
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
var exists bool
|
||||
if err := pg.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM mk_product_performance_grouped_snapshot_meta
|
||||
WHERE report_key = $1
|
||||
)
|
||||
`, reportKey).Scan(&exists); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
out = filterProductPerformanceGroupedRowsForExpansion(out, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit)
|
||||
out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending)
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
type productPerformanceGroupedSortNode struct {
|
||||
Row map[string]any
|
||||
Children []*productPerformanceGroupedSortNode
|
||||
}
|
||||
|
||||
func sortProductPerformancePreparedGroupedRows(rows []map[string]any, sortBy string, descending bool) []map[string]any {
|
||||
sortBy = strings.TrimSpace(sortBy)
|
||||
if len(rows) == 0 || sortBy == "" {
|
||||
return rows
|
||||
}
|
||||
roots := make([]*productPerformanceGroupedSortNode, 0)
|
||||
stack := make([]*productPerformanceGroupedSortNode, 0, 16)
|
||||
for _, row := range rows {
|
||||
node := &productPerformanceGroupedSortNode{Row: row}
|
||||
level := intFromMap(row, "level")
|
||||
if level < 0 {
|
||||
level = 0
|
||||
}
|
||||
if level < len(stack) {
|
||||
stack = stack[:level]
|
||||
}
|
||||
if level > 0 && level-1 < len(stack) {
|
||||
stack[level-1].Children = append(stack[level-1].Children, node)
|
||||
} else {
|
||||
roots = append(roots, node)
|
||||
}
|
||||
if level >= len(stack) {
|
||||
stack = append(stack, node)
|
||||
} else {
|
||||
stack[level] = node
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
var appendNodes func(nodes []*productPerformanceGroupedSortNode)
|
||||
appendNodes = func(nodes []*productPerformanceGroupedSortNode) {
|
||||
sort.SliceStable(nodes, func(i, j int) bool {
|
||||
cmp := compareProductPerformanceGroupedSortRows(nodes[i].Row, nodes[j].Row, sortBy)
|
||||
if cmp == 0 {
|
||||
cmp = strings.Compare(strings.ToLower(stringFromMap(nodes[i].Row, "label")), strings.ToLower(stringFromMap(nodes[j].Row, "label")))
|
||||
}
|
||||
if descending {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
for _, node := range nodes {
|
||||
out = append(out, node.Row)
|
||||
if len(node.Children) > 0 {
|
||||
appendNodes(node.Children)
|
||||
}
|
||||
}
|
||||
}
|
||||
appendNodes(roots)
|
||||
return out
|
||||
}
|
||||
|
||||
func compareProductPerformanceGroupedSortRows(left, right map[string]any, sortBy string) int {
|
||||
leftValue := productPerformanceGroupedSortValue(left, sortBy)
|
||||
rightValue := productPerformanceGroupedSortValue(right, sortBy)
|
||||
leftNum, leftOK := numericProductPerformanceGroupedSortValue(leftValue)
|
||||
rightNum, rightOK := numericProductPerformanceGroupedSortValue(rightValue)
|
||||
if leftOK && rightOK {
|
||||
switch {
|
||||
case leftNum < rightNum:
|
||||
return -1
|
||||
case leftNum > rightNum:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
leftText := strings.ToLower(strings.TrimSpace(fmt.Sprint(leftValue)))
|
||||
rightText := strings.ToLower(strings.TrimSpace(fmt.Sprint(rightValue)))
|
||||
return strings.Compare(leftText, rightText)
|
||||
}
|
||||
|
||||
func productPerformanceGroupedSortValue(row map[string]any, sortBy string) any {
|
||||
sortBy = strings.TrimSpace(sortBy)
|
||||
if sortBy == "" {
|
||||
return ""
|
||||
}
|
||||
if value, ok := row[sortBy]; ok {
|
||||
return value
|
||||
}
|
||||
switch sortBy {
|
||||
case "performance_score_total", "performance_score_90d":
|
||||
return row["performance_score"]
|
||||
case "gross_margin_base_total":
|
||||
return row["gross_margin_total"]
|
||||
case "gross_margin_cost_total":
|
||||
return row["gross_margin_total"]
|
||||
case "color_yaka":
|
||||
return mapGroupValue(row, "color_yaka")
|
||||
case "market_key":
|
||||
return mapGroupValue(row, "market_key")
|
||||
default:
|
||||
if sortBy == stringFromMap(row, "group_field") {
|
||||
return stringFromMap(row, "group_value")
|
||||
}
|
||||
return stringFromMap(row, "label")
|
||||
}
|
||||
}
|
||||
|
||||
func numericProductPerformanceGroupedSortValue(value any) (float64, bool) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return 0, false
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case json.Number:
|
||||
f, err := v.Float64()
|
||||
return f, err == nil
|
||||
case string:
|
||||
text := strings.TrimSpace(v)
|
||||
if text == "" {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(text, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
type productPerformanceSQLGroupFilter struct {
|
||||
Field string
|
||||
Value string
|
||||
Field string
|
||||
Value string
|
||||
Values []string
|
||||
}
|
||||
|
||||
func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) {
|
||||
@@ -3163,25 +4415,62 @@ func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req Produ
|
||||
}
|
||||
out := make([]map[string]any, 0, 512)
|
||||
filters := productPerformanceGroupedBaseFilters(req)
|
||||
err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, 0, []string{"tab:" + mode}, filters, req.ExpandedKeys, req.Limit)
|
||||
return out, true, err
|
||||
err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, 0, []string{"tab:" + mode}, filters, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit)
|
||||
if err != nil {
|
||||
return out, true, err
|
||||
}
|
||||
out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending)
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
func productPerformanceGroupedBaseFilters(req ProductPerformanceGroupedRequest) []productPerformanceSQLGroupFilter {
|
||||
mainGroup := strings.TrimSpace(req.MainGroup)
|
||||
if strings.TrimSpace(req.Mode) != "product_detail" || mainGroup == "" {
|
||||
return nil
|
||||
effectiveFilters := productPerformanceGroupedEffectiveFilters(req)
|
||||
filters := make([]productPerformanceSQLGroupFilter, 0, len(effectiveFilters))
|
||||
for field, values := range effectiveFilters {
|
||||
cleanValues := cleanProductPerformanceFilterValues(values)
|
||||
if len(cleanValues) == 0 {
|
||||
continue
|
||||
}
|
||||
filters = append(filters, productPerformanceSQLGroupFilter{Field: field, Values: cleanValues})
|
||||
}
|
||||
return []productPerformanceSQLGroupFilter{{Field: "urun_ana_grubu", Value: mainGroup}}
|
||||
return filters
|
||||
}
|
||||
|
||||
func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, visualLevel int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, limit int) error {
|
||||
if level >= len(levels) {
|
||||
rows, err := queryProductPerformanceSQLLeafRows(ctx, pg, mode, filters, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
func productPerformanceGroupedEffectiveFilters(req ProductPerformanceGroupedRequest) map[string][]string {
|
||||
filters := make(map[string][]string, len(req.Filters)+1)
|
||||
for field, values := range req.Filters {
|
||||
cleanValues := cleanProductPerformanceFilterValues(values)
|
||||
if len(cleanValues) == 0 {
|
||||
continue
|
||||
}
|
||||
*out = append(*out, rows...)
|
||||
filters[field] = cleanValues
|
||||
}
|
||||
if strings.TrimSpace(req.Mode) == "product_detail" {
|
||||
mainGroup := cleanProductPerformanceFilterValues([]string{req.MainGroup})
|
||||
if len(mainGroup) > 0 {
|
||||
filters["urun_ana_grubu"] = mainGroup
|
||||
}
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
func productPerformancePreparedGroupedEffectiveFilters(req ProductPerformanceGroupedRequest) map[string][]string {
|
||||
filters := make(map[string][]string, len(req.Filters))
|
||||
for field, values := range req.Filters {
|
||||
if strings.TrimSpace(req.Mode) == "product_detail" && field == "urun_ana_grubu" {
|
||||
continue
|
||||
}
|
||||
cleanValues := cleanProductPerformanceFilterValues(values)
|
||||
if len(cleanValues) == 0 {
|
||||
continue
|
||||
}
|
||||
filters[field] = cleanValues
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, visualLevel int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, expandThroughLevel int, limit int) error {
|
||||
if level >= len(levels) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3194,7 +4483,7 @@ func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out
|
||||
value := stringFromMap(row, "group_value")
|
||||
if shouldSkipProductPerformanceGroupValue(field, value) {
|
||||
nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value})
|
||||
if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel, parentKeys, nextFilters, expandedKeys, limit); err != nil {
|
||||
if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel, parentKeys, nextFilters, expandedKeys, expandThroughLevel, limit); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
@@ -3209,10 +4498,11 @@ func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out
|
||||
row["label"] = value
|
||||
row["__group"] = true
|
||||
row[field] = value
|
||||
deriveProductPerformanceGroupMetrics(row, field)
|
||||
*out = append(*out, row)
|
||||
if expandedKeys[key] {
|
||||
if expandedKeys[key] || level <= expandThroughLevel {
|
||||
nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value})
|
||||
if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel+1, append(parentKeys, keyPart), nextFilters, expandedKeys, limit); err != nil {
|
||||
if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel+1, append(parentKeys, keyPart), nextFilters, expandedKeys, expandThroughLevel, limit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -3234,24 +4524,30 @@ SELECT (
|
||||
jsonb_build_object(
|
||||
'group_value', group_value,
|
||||
'label', group_value,
|
||||
'period_start', '2022-01-01',
|
||||
'period_end', COALESCE(to_char((SELECT kpi_date FROM LatestKPIDate),'YYYY-MM-DD'), ''),
|
||||
'count', row_count,
|
||||
'recommendation', CASE WHEN recommendation <> '' THEN recommendation ELSE row_count::text || ' satir' END,
|
||||
'image_product_code', image_product_code,
|
||||
'image_color_code', image_color_code,
|
||||
'image_yaka_kodu', image_yaka_kodu,
|
||||
'product_code', CASE WHEN $%d = 'product_code' THEN group_value ELSE product_code END,
|
||||
'color_code', CASE WHEN $%d = 'color_code' THEN group_value ELSE color_code END,
|
||||
'yaka_kodu', CASE WHEN $%d = 'yaka_kodu' THEN group_value ELSE yaka_kodu END,
|
||||
'item_description', item_description,
|
||||
'kategori', CASE WHEN $%d = 'kategori' THEN group_value ELSE kategori END,
|
||||
'askili_yan', CASE WHEN $%d = 'askili_yan' THEN group_value ELSE askili_yan END,
|
||||
'urun_ilk_grubu', CASE WHEN $%d = 'urun_ilk_grubu' THEN group_value ELSE urun_ilk_grubu END,
|
||||
'urun_ana_grubu', CASE WHEN $%d = 'urun_ana_grubu' THEN group_value ELSE urun_ana_grubu END,
|
||||
'urun_alt_grubu', CASE WHEN $%d = 'urun_alt_grubu' THEN group_value ELSE urun_alt_grubu END,
|
||||
'market_key', CASE WHEN $%d = 'market_key' THEN group_value ELSE market_key END
|
||||
'product_code', CASE WHEN $%d = 'product_code' THEN group_value ELSE '' END,
|
||||
'color_code', CASE WHEN $%d = 'color_code' THEN group_value ELSE '' END,
|
||||
'yaka_kodu', CASE WHEN $%d = 'yaka_kodu' THEN group_value ELSE '' END,
|
||||
'item_description', CASE WHEN $%d = 'item_description' THEN group_value ELSE '' END,
|
||||
'kategori', CASE WHEN $%d = 'kategori' THEN group_value ELSE '' END,
|
||||
'askili_yan', CASE WHEN $%d = 'askili_yan' THEN group_value ELSE '' END,
|
||||
'urun_ilk_grubu', CASE WHEN $%d = 'urun_ilk_grubu' THEN group_value ELSE '' END,
|
||||
'urun_ana_grubu', CASE WHEN $%d = 'urun_ana_grubu' THEN group_value ELSE '' END,
|
||||
'urun_alt_grubu', CASE WHEN $%d = 'urun_alt_grubu' THEN group_value ELSE '' END,
|
||||
'market_key', CASE WHEN $%d = 'market_key' THEN group_value ELSE '' END
|
||||
)
|
||||
|| jsonb_build_object(
|
||||
'stock_qty', stock_qty,
|
||||
'avg_stock_90d', avg_stock_90d,
|
||||
'avg_stock_180d', avg_stock_180d,
|
||||
'avg_stock_365d', avg_stock_365d,
|
||||
'avg_stock_total', avg_stock_total,
|
||||
'sales_qty_90d', sales_qty_90d,
|
||||
'sales_qty_180d', sales_qty_180d,
|
||||
'sales_qty_365d', sales_qty_365d,
|
||||
@@ -3276,8 +4572,17 @@ SELECT (
|
||||
'unit_profit_base_90d', unit_profit_base_90d,
|
||||
'unit_profit_base_180d', unit_profit_base_180d,
|
||||
'market_count_90d', market_count_90d,
|
||||
'market_count_180d', market_count_180d,
|
||||
'market_count_365d', market_count_365d,
|
||||
'market_count_total', market_count_total,
|
||||
'customer_count_90d', customer_count_90d,
|
||||
'customer_count_180d', customer_count_180d,
|
||||
'customer_count_365d', customer_count_365d,
|
||||
'customer_count_total', customer_count_total,
|
||||
'sales_index_90d', sales_index_90d,
|
||||
'sales_index_180d', sales_index_180d,
|
||||
'sales_index_365d', sales_index_365d,
|
||||
'sales_index_total', sales_index_total,
|
||||
'price_index_90d', price_index_90d,
|
||||
'margin_index_90d', margin_index_90d,
|
||||
'performance_score', performance_score,
|
||||
@@ -3287,10 +4592,10 @@ SELECT (
|
||||
'stock_days_180d', stock_days_180d,
|
||||
'stock_days_365d', stock_days_365d,
|
||||
'stock_days_total', stock_days_total,
|
||||
'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END,
|
||||
'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) ELSE 0 END,
|
||||
'stock_turnover_365d', CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END,
|
||||
'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) ELSE 0 END
|
||||
'stock_turnover_90d', stock_turnover_90d,
|
||||
'stock_turnover_180d', stock_turnover_180d,
|
||||
'stock_turnover_365d', stock_turnover_365d,
|
||||
'stock_turnover_total', stock_turnover_total
|
||||
)
|
||||
) AS row_json
|
||||
FROM (
|
||||
@@ -3330,22 +4635,22 @@ FROM (
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_usd_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS avg_price_usd_90d,
|
||||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_usd_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS avg_price_usd_180d,
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0
|
||||
THEN SUM(CASE WHEN sales_qty_total > 0 THEN cost_price_usd * sales_qty_total WHEN stock_variant_rank = 1 THEN cost_price_usd * stock_qty ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0)
|
||||
ELSE 0
|
||||
WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END) > 0
|
||||
THEN SUM(CASE WHEN sales_qty_total > 0 THEN cost_price_usd * sales_qty_total ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END),0)
|
||||
ELSE COALESCE(AVG(NULLIF(cost_price_usd,0)),0)
|
||||
END AS cost_price_usd,
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0
|
||||
THEN SUM(CASE WHEN sales_qty_total > 0 THEN base_price_usd * sales_qty_total WHEN stock_variant_rank = 1 THEN base_price_usd * stock_qty ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0)
|
||||
ELSE 0
|
||||
WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END) > 0
|
||||
THEN SUM(CASE WHEN sales_qty_total > 0 THEN base_price_usd * sales_qty_total ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END),0)
|
||||
ELSE COALESCE(AVG(NULLIF(base_price_usd,0)),0)
|
||||
END AS base_price_usd,
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0
|
||||
THEN SUM(CASE WHEN sales_qty_total > 0 THEN base_price_try * sales_qty_total WHEN stock_variant_rank = 1 THEN base_price_try * stock_qty ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0)
|
||||
ELSE 0
|
||||
WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END) > 0
|
||||
THEN SUM(CASE WHEN sales_qty_total > 0 THEN base_price_try * sales_qty_total ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END),0)
|
||||
ELSE COALESCE(AVG(NULLIF(base_price_try,0)),0)
|
||||
END AS base_price_try,
|
||||
COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d,
|
||||
COALESCE(SUM(gross_profit_usd_180d),0) AS gross_profit_usd_180d,
|
||||
@@ -3355,18 +4660,49 @@ FROM (
|
||||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_cost_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_cost_180d,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_base_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS unit_profit_base_90d,
|
||||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_base_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_base_180d,
|
||||
COALESCE(SUM(market_count_90d),0)::integer AS market_count_90d,
|
||||
COALESCE(SUM(customer_count_90d),0)::integer AS customer_count_90d,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_90d,0) > 0)::integer AS market_count_90d,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_180d,0) > 0)::integer AS market_count_180d,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_365d,0) > 0)::integer AS market_count_365d,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_total,0) > 0)::integer AS market_count_total,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_90d,0) > 0 THEN customer_count_90d ELSE 0 END),0)::integer AS customer_count_90d,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_180d,0) > 0 THEN customer_count_180d ELSE 0 END),0)::integer AS customer_count_180d,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_365d,0) > 0 THEN customer_count_365d ELSE 0 END),0)::integer AS customer_count_365d,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_total,0) > 0 THEN customer_count_total ELSE 0 END),0)::integer AS customer_count_total,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS sales_index_90d,
|
||||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_index_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS sales_index_180d,
|
||||
CASE WHEN SUM(sales_qty_365d) > 0 THEN SUM(sales_index_365d * sales_qty_365d) / NULLIF(SUM(sales_qty_365d),0) ELSE 0 END AS sales_index_365d,
|
||||
CASE WHEN SUM(sales_qty_total) > 0 THEN SUM(sales_index_total * sales_qty_total) / NULLIF(SUM(sales_qty_total),0) ELSE 0 END AS sales_index_total,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS price_index_90d,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS margin_index_90d,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(performance_score * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS performance_score,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(stock_turnover_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS stock_turnover_90d,
|
||||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(stock_turnover_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS stock_turnover_180d,
|
||||
CASE WHEN SUM(sales_qty_365d) > 0 THEN SUM(stock_turnover_365d * sales_qty_365d) / NULLIF(SUM(sales_qty_365d),0) ELSE 0 END AS stock_turnover_365d,
|
||||
CASE WHEN SUM(sales_qty_total) > 0 THEN SUM(stock_turnover_total * sales_qty_total) / NULLIF(SUM(sales_qty_total),0) ELSE 0 END AS stock_turnover_total,
|
||||
CASE
|
||||
WHEN SUM(CASE
|
||||
WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d
|
||||
ELSE 0
|
||||
END) > 0
|
||||
THEN SUM(performance_score * CASE
|
||||
WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d
|
||||
ELSE 0
|
||||
END) / NULLIF(SUM(CASE
|
||||
WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d
|
||||
ELSE 0
|
||||
END),0)
|
||||
ELSE COALESCE(AVG(performance_score),0)
|
||||
END AS performance_score,
|
||||
MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket,
|
||||
COALESCE(MODE() WITHIN GROUP (ORDER BY NULLIF(recommendation,'')), '') AS recommendation,
|
||||
COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN idle_cost_usd ELSE 0 END),0) AS idle_cost_usd
|
||||
FROM (
|
||||
SELECT
|
||||
Source.*,
|
||||
btrim(regexp_replace(COALESCE(Source.market_key,''), '^.*[|]', '')) AS display_market_key,
|
||||
CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END AS stock_turnover_90d,
|
||||
CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END AS stock_turnover_180d,
|
||||
CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END AS stock_turnover_365d,
|
||||
CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, ((SELECT kpi_date FROM LatestKPIDate) - DATE '2022-01-01') + 1) ELSE 0 END AS stock_turnover_total,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(%s, ''), product_code, color_code, yaka_kodu
|
||||
ORDER BY performance_score DESC NULLS LAST
|
||||
@@ -3377,7 +4713,7 @@ FROM (
|
||||
GROUP BY COALESCE(%s, '')
|
||||
) g
|
||||
ORDER BY group_value
|
||||
`, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, groupExpr, groupExpr, whereSQL, groupExpr)
|
||||
`, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, groupExpr, groupExpr, whereSQL, groupExpr)
|
||||
args = append(args, field)
|
||||
return queryProductPerformanceJSONRows(ctx, pg, query, args...)
|
||||
}
|
||||
@@ -3391,10 +4727,10 @@ func queryProductPerformanceSQLLeafRows(ctx context.Context, pg *sql.DB, mode st
|
||||
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
|
||||
SELECT to_jsonb(t) || jsonb_build_object(
|
||||
'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key,
|
||||
'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END,
|
||||
'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) ELSE 0 END,
|
||||
'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END,
|
||||
'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END,
|
||||
'stock_turnover_365d', CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END,
|
||||
'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) ELSE 0 END
|
||||
'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, ((SELECT kpi_date FROM LatestKPIDate) - DATE '2022-01-01') + 1) ELSE 0 END
|
||||
) AS row_json
|
||||
FROM Source t
|
||||
%s
|
||||
@@ -3486,17 +4822,17 @@ Source AS (
|
||||
CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan,
|
||||
CASE
|
||||
WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN ''
|
||||
WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN ''
|
||||
WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN ''
|
||||
ELSE COALESCE(urun_ilk_grubu,'')
|
||||
END AS urun_ilk_grubu,
|
||||
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
|
||||
urun_alt_grubu,
|
||||
market_key,
|
||||
COALESCE(stock_qty,0) AS stock_qty,
|
||||
(COALESCE((SELECT s.stock_qty FROM Stock90Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0 AS avg_stock_90d,
|
||||
(COALESCE((SELECT s.stock_qty FROM Stock180Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0 AS avg_stock_180d,
|
||||
(COALESCE((SELECT s.stock_qty FROM Stock365Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0 AS avg_stock_365d,
|
||||
(COALESCE((SELECT s.stock_qty FROM StockTotalStart s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0 AS avg_stock_total,
|
||||
COALESCE(NULLIF(avg_stock_90d,0), (COALESCE((SELECT s.stock_qty FROM Stock90Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_90d,
|
||||
COALESCE(NULLIF(avg_stock_180d,0), (COALESCE((SELECT s.stock_qty FROM Stock180Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_180d,
|
||||
COALESCE(NULLIF(avg_stock_365d,0), (COALESCE((SELECT s.stock_qty FROM Stock365Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_365d,
|
||||
COALESCE(NULLIF(avg_stock_total,0), (COALESCE((SELECT s.stock_qty FROM StockTotalStart s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_total,
|
||||
COALESCE(sales_qty_90d,0) AS sales_qty_90d,
|
||||
COALESCE(sales_qty_180d,0) AS sales_qty_180d,
|
||||
COALESCE(sales_qty_365d,0) AS sales_qty_365d,
|
||||
@@ -3523,8 +4859,17 @@ Source AS (
|
||||
COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d,
|
||||
COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d,
|
||||
COALESCE(market_count_90d,0) AS market_count_90d,
|
||||
COALESCE(market_count_180d,0) AS market_count_180d,
|
||||
COALESCE(market_count_365d,0) AS market_count_365d,
|
||||
COALESCE(market_count_total,0) AS market_count_total,
|
||||
COALESCE(customer_count_90d,0) AS customer_count_90d,
|
||||
COALESCE(customer_count_180d,0) AS customer_count_180d,
|
||||
COALESCE(customer_count_365d,0) AS customer_count_365d,
|
||||
COALESCE(customer_count_total,0) AS customer_count_total,
|
||||
COALESCE(sales_index_90d,0) AS sales_index_90d,
|
||||
COALESCE(sales_index_180d,0) AS sales_index_180d,
|
||||
COALESCE(sales_index_365d,0) AS sales_index_365d,
|
||||
COALESCE(sales_index_total,0) AS sales_index_total,
|
||||
COALESCE(price_index_90d,0) AS price_index_90d,
|
||||
COALESCE(margin_index_90d,0) AS margin_index_90d,
|
||||
COALESCE(performance_score,0) AS performance_score,
|
||||
@@ -3533,22 +4878,24 @@ Source AS (
|
||||
COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')` + idleWhere + `
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')` + idleWhere + `
|
||||
)`
|
||||
}
|
||||
|
||||
func productPerformanceSQLGroupExpr(field string) (string, bool) {
|
||||
switch field {
|
||||
case "urun_ilk_grubu":
|
||||
return "CASE WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE COALESCE(urun_ilk_grubu,'') END", true
|
||||
return productPerformanceCleanFirstGroupSQL("urun_ilk_grubu"), true
|
||||
case "askili_yan":
|
||||
return "CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END", true
|
||||
case "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu":
|
||||
case "urun_ana_grubu":
|
||||
return "COALESCE(NULLIF(btrim(COALESCE(urun_ana_grubu,'')), ''), NULLIF(btrim(COALESCE(urun_alt_grubu,'')), ''), NULLIF(btrim(COALESCE(kategori,'')), ''), '')", true
|
||||
case "kategori", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu", "performance_bucket":
|
||||
return field, true
|
||||
case "color_yaka":
|
||||
return "concat_ws('/', NULLIF(btrim(COALESCE(color_code,'')), ''), NULLIF(btrim(COALESCE(yaka_kodu,'')), ''))", true
|
||||
case "market_key":
|
||||
return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true
|
||||
return "btrim(regexp_replace(COALESCE(market_key,''), '^.*[|]', ''))", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
@@ -3574,13 +4921,276 @@ func productPerformanceSQLFilterWhere(filters []productPerformanceSQLGroupFilter
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("unsupported product performance filter field: %s", filter.Field)
|
||||
}
|
||||
args = append(args, normalizeProductPerformanceGroupValue(filter.Value))
|
||||
parts = append(parts, fmt.Sprintf("COALESCE(%s, '') = $%d", expr, len(args)))
|
||||
values := filter.Values
|
||||
if len(values) == 0 && strings.TrimSpace(filter.Value) != "" {
|
||||
values = []string{filter.Value}
|
||||
}
|
||||
values = cleanProductPerformanceFilterValues(values)
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
args = append(args, pq.Array(values))
|
||||
parts = append(parts, fmt.Sprintf("COALESCE(%s, '') = ANY($%d)", expr, len(args)))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
return "WHERE " + strings.Join(parts, " AND "), args, nil
|
||||
}
|
||||
|
||||
func cleanProductPerformanceFilterValues(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
clean := normalizeProductPerformanceGroupValue(value)
|
||||
if clean == "" || seen[clean] {
|
||||
continue
|
||||
}
|
||||
seen[clean] = true
|
||||
out = append(out, clean)
|
||||
if len(out) >= 300 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterProductPerformanceGroupedRows(rows []map[string]any, filters map[string][]string) []map[string]any {
|
||||
if len(filters) == 0 || len(rows) == 0 {
|
||||
return rows
|
||||
}
|
||||
cleanFilters := map[string]map[string]bool{}
|
||||
for field, values := range filters {
|
||||
cleanValues := cleanProductPerformanceFilterValues(values)
|
||||
if len(cleanValues) == 0 {
|
||||
continue
|
||||
}
|
||||
set := map[string]bool{}
|
||||
for _, value := range cleanValues {
|
||||
set[value] = true
|
||||
}
|
||||
cleanFilters[field] = set
|
||||
}
|
||||
if len(cleanFilters) == 0 {
|
||||
return rows
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
matches := true
|
||||
for field, allowed := range cleanFilters {
|
||||
if !allowed[productPerformanceGroupedFilterValue(row, field)] {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterProductPerformancePreparedGroupedRows(rows []map[string]any, filters map[string][]string) []map[string]any {
|
||||
if len(filters) == 0 || len(rows) == 0 {
|
||||
return rows
|
||||
}
|
||||
cleanFilters := map[string]map[string]bool{}
|
||||
for field, values := range filters {
|
||||
cleanValues := cleanProductPerformanceFilterValues(values)
|
||||
if len(cleanValues) == 0 {
|
||||
continue
|
||||
}
|
||||
set := map[string]bool{}
|
||||
for _, value := range cleanValues {
|
||||
set[value] = true
|
||||
}
|
||||
cleanFilters[field] = set
|
||||
}
|
||||
if len(cleanFilters) == 0 {
|
||||
return rows
|
||||
}
|
||||
|
||||
rowByKey := make(map[string]map[string]any, len(rows))
|
||||
for _, row := range rows {
|
||||
if key := stringFromMap(row, "key"); key != "" {
|
||||
rowByKey[key] = row
|
||||
}
|
||||
}
|
||||
|
||||
matchedKeys := map[string]bool{}
|
||||
allowedKeys := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
key := stringFromMap(row, "key")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if !productPerformancePreparedGroupedPathMatches(key, rowByKey, cleanFilters) {
|
||||
continue
|
||||
}
|
||||
matchedKeys[key] = true
|
||||
for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) {
|
||||
allowedKeys[ancestor] = true
|
||||
}
|
||||
}
|
||||
if len(allowedKeys) == 0 {
|
||||
return []map[string]any{}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
key := stringFromMap(row, "key")
|
||||
if allowedKeys[key] || productPerformanceGroupRowHasMatchedAncestor(key, matchedKeys) {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func productPerformancePreparedGroupedPathMatches(key string, rowByKey map[string]map[string]any, filters map[string]map[string]bool) bool {
|
||||
pathKeys := productPerformanceGroupKeyAncestors(key, true)
|
||||
if len(pathKeys) == 0 {
|
||||
pathKeys = []string{key}
|
||||
}
|
||||
for field, allowed := range filters {
|
||||
matched := false
|
||||
for _, pathKey := range pathKeys {
|
||||
row := rowByKey[pathKey]
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
if allowed[productPerformancePreparedGroupedFilterValue(row, field)] {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func productPerformancePreparedGroupedFilterValue(row map[string]any, field string) string {
|
||||
field = strings.TrimSpace(field)
|
||||
groupField := strings.TrimSpace(stringFromMap(row, "group_field"))
|
||||
if groupField == field {
|
||||
return normalizeProductPerformanceGroupValue(stringFromMap(row, "group_value"))
|
||||
}
|
||||
switch field {
|
||||
case "market_key", "color_yaka", "urun_ilk_grubu", "askili_yan":
|
||||
value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if value := normalizeProductPerformanceGroupValue(stringFromMap(row, field)); value != "" {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func productPerformanceGroupRowHasMatchedAncestor(key string, matchedKeys map[string]bool) bool {
|
||||
for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) {
|
||||
if matchedKeys[ancestor] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterProductPerformanceGroupedRowsForExpansion(rows []map[string]any, expandedKeys map[string]bool, expandThroughLevel int, limit int) []map[string]any {
|
||||
if len(rows) == 0 {
|
||||
return rows
|
||||
}
|
||||
if limit <= 0 || limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
out := make([]map[string]any, 0, minInt(limit, len(rows)))
|
||||
for _, row := range rows {
|
||||
if !productPerformanceGroupedRowVisible(row, expandedKeys, expandThroughLevel) {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func productPerformanceGroupedRowVisible(row map[string]any, expandedKeys map[string]bool, expandThroughLevel int) bool {
|
||||
level := intFromMap(row, "level")
|
||||
if level <= 0 {
|
||||
return true
|
||||
}
|
||||
key := stringFromMap(row, "key")
|
||||
if key == "" {
|
||||
return true
|
||||
}
|
||||
ancestors := productPerformanceGroupKeyAncestors(key, false)
|
||||
for ancestorLevel, ancestor := range ancestors {
|
||||
if ancestorLevel <= expandThroughLevel {
|
||||
continue
|
||||
}
|
||||
if expandedKeys != nil && expandedKeys[ancestor] {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func productPerformanceGroupKeyAncestors(key string, includeSelf bool) []string {
|
||||
parts := strings.Split(strings.TrimSpace(key), "|")
|
||||
if len(parts) <= 1 {
|
||||
return nil
|
||||
}
|
||||
last := len(parts) - 1
|
||||
if !includeSelf {
|
||||
last--
|
||||
}
|
||||
if last < 1 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, last)
|
||||
for i := 1; i <= last; i++ {
|
||||
out = append(out, strings.Join(parts[:i+1], "|"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func productPerformanceParentGroupKey(key string) string {
|
||||
parts := strings.Split(strings.TrimSpace(key), "|")
|
||||
if len(parts) <= 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts[:len(parts)-1], "|")
|
||||
}
|
||||
|
||||
func productPerformanceGroupedFilterValue(row map[string]any, field string) string {
|
||||
switch field {
|
||||
case "urun_ana_grubu":
|
||||
value := strings.TrimSpace(stringFromMap(row, "urun_ana_grubu"))
|
||||
if value != "" {
|
||||
return normalizeProductPerformanceGroupValue(value)
|
||||
}
|
||||
if value = strings.TrimSpace(stringFromMap(row, "urun_alt_grubu")); value != "" {
|
||||
return normalizeProductPerformanceGroupValue(value)
|
||||
}
|
||||
return normalizeProductPerformanceGroupValue(stringFromMap(row, "kategori"))
|
||||
case "market_key", "color_yaka", "urun_ilk_grubu", "askili_yan":
|
||||
return normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
|
||||
default:
|
||||
return normalizeProductPerformanceGroupValue(stringFromMap(row, field))
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
||||
if rows, err := productPerformanceGroupedRawSnapshotSourceRows(ctx, pg, mode, limit); err != nil {
|
||||
return nil, err
|
||||
} else if rows != nil {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "products":
|
||||
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||||
@@ -3606,6 +5216,257 @@ func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode s
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceGroupedRawSnapshotSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
||||
reportKey, ok := productPerformanceGroupedSnapshotKey(mode)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
rows, exists, err := loadProductPerformanceSnapshotMapRows(ctx, pg, reportKey, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(mode) == "idle" {
|
||||
normalizeProductPerformanceGroupedSourceScores(rows, mode)
|
||||
return productPerformanceIdleSourceRows(rows), nil
|
||||
}
|
||||
if mode == "products" || mode == "product_detail" {
|
||||
rows = mergeProductPerformanceGeneralSnapshotMetrics(ctx, pg, rows)
|
||||
rows = mergeProductPerformanceSalesSpreadKeys(ctx, pg, rows)
|
||||
}
|
||||
normalizeProductPerformanceGroupedSourceScores(rows, mode)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func normalizeProductPerformanceGroupedSourceScores(rows []map[string]any, mode string) {
|
||||
if !shouldCascadeProductPerformanceGroupedScores(mode) {
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
deriveProductPerformanceGroupMetrics(row, "")
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
row["performance_score_"+suffix] = productPerformanceSalesPeriodScore(row, suffix)
|
||||
}
|
||||
row["performance_score"] = row["performance_score_90d"]
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProductPerformanceGeneralSnapshotMetrics(ctx context.Context, pg *sql.DB, rows []map[string]any) []map[string]any {
|
||||
if len(rows) == 0 {
|
||||
return rows
|
||||
}
|
||||
generalRows, ok, err := loadProductPerformanceSnapshotMapRows(ctx, pg, productPerformanceSnapshotKey("general"), 50000)
|
||||
if err != nil || !ok || len(generalRows) == 0 {
|
||||
return rows
|
||||
}
|
||||
byKey := make(map[string]map[string]any, len(generalRows))
|
||||
for _, row := range generalRows {
|
||||
key := productPerformanceMapMarketVariantKey(row)
|
||||
if key != "" {
|
||||
byKey[key] = row
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
general := byKey[productPerformanceMapMarketVariantKey(row)]
|
||||
if general == nil {
|
||||
continue
|
||||
}
|
||||
for _, field := range []string{
|
||||
"market_count_total", "customer_count_total", "invoice_count_total", "sales_index_total",
|
||||
"avg_price_usd_total", "gross_profit_usd_total", "gross_margin_total",
|
||||
"unit_profit_cost_total", "unit_profit_base_total", "first_sale_date",
|
||||
} {
|
||||
if value, ok := general[field]; ok {
|
||||
row[field] = value
|
||||
}
|
||||
}
|
||||
if value, ok := general["performance_score"]; ok {
|
||||
if _, exists := row["performance_score_total"]; !exists {
|
||||
row["performance_score_total"] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func mergeProductPerformanceSalesSpreadKeys(ctx context.Context, pg *sql.DB, rows []map[string]any) []map[string]any {
|
||||
if len(rows) == 0 {
|
||||
return rows
|
||||
}
|
||||
const query = `
|
||||
WITH Latest AS (
|
||||
SELECT COALESCE(
|
||||
(SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily),
|
||||
(SELECT MAX(sales_date) FROM mk_product_performance_sales_daily),
|
||||
current_date
|
||||
)::date AS kpi_date
|
||||
),
|
||||
Spread AS (
|
||||
SELECT
|
||||
s.product_code,
|
||||
s.color_code,
|
||||
s.yaka_kodu,
|
||||
s.market_key,
|
||||
COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER (
|
||||
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '89 days' AND Latest.kpi_date
|
||||
AND COALESCE(s.sales_usd,0) > 0
|
||||
AND display_market_key NOT IN ('', 'STOK', '-')
|
||||
), '[]'::jsonb) AS market_keys_90d,
|
||||
COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER (
|
||||
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '179 days' AND Latest.kpi_date
|
||||
AND COALESCE(s.sales_usd,0) > 0
|
||||
AND display_market_key NOT IN ('', 'STOK', '-')
|
||||
), '[]'::jsonb) AS market_keys_180d,
|
||||
COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER (
|
||||
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '359 days' AND Latest.kpi_date
|
||||
AND COALESCE(s.sales_usd,0) > 0
|
||||
AND display_market_key NOT IN ('', 'STOK', '-')
|
||||
), '[]'::jsonb) AS market_keys_365d,
|
||||
COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER (
|
||||
WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date
|
||||
AND COALESCE(s.sales_usd,0) > 0
|
||||
AND display_market_key NOT IN ('', 'STOK', '-')
|
||||
), '[]'::jsonb) AS market_keys_total,
|
||||
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
|
||||
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '89 days' AND Latest.kpi_date
|
||||
AND COALESCE(s.sales_usd,0) > 0
|
||||
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
|
||||
), '[]'::jsonb) AS customer_keys_90d,
|
||||
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
|
||||
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '179 days' AND Latest.kpi_date
|
||||
AND COALESCE(s.sales_usd,0) > 0
|
||||
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
|
||||
), '[]'::jsonb) AS customer_keys_180d,
|
||||
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
|
||||
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '359 days' AND Latest.kpi_date
|
||||
AND COALESCE(s.sales_usd,0) > 0
|
||||
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
|
||||
), '[]'::jsonb) AS customer_keys_365d,
|
||||
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
|
||||
WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date
|
||||
AND COALESCE(s.sales_usd,0) > 0
|
||||
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
|
||||
), '[]'::jsonb) AS customer_keys_total
|
||||
FROM (
|
||||
SELECT
|
||||
s.*,
|
||||
btrim(regexp_replace(COALESCE(s.market_key,''), '^.*[|]', '')) AS display_market_key
|
||||
FROM mk_product_performance_sales_daily s
|
||||
) s
|
||||
CROSS JOIN Latest
|
||||
WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date
|
||||
AND upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
GROUP BY s.product_code, s.color_code, s.yaka_kodu, s.market_key
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'product_code', product_code,
|
||||
'color_code', color_code,
|
||||
'yaka_kodu', yaka_kodu,
|
||||
'market_key', market_key,
|
||||
'__market_keys_90d', market_keys_90d,
|
||||
'__market_keys_180d', market_keys_180d,
|
||||
'__market_keys_365d', market_keys_365d,
|
||||
'__market_keys_total', market_keys_total,
|
||||
'__customer_keys_90d', customer_keys_90d,
|
||||
'__customer_keys_180d', customer_keys_180d,
|
||||
'__customer_keys_365d', customer_keys_365d,
|
||||
'__customer_keys_total', customer_keys_total
|
||||
)
|
||||
FROM Spread`
|
||||
spreadRows, err := queryProductPerformanceJSONRows(ctx, pg, query)
|
||||
if err != nil {
|
||||
log.Printf("[ProductPerformanceRefresh] product sales spread keys skipped err=%v", err)
|
||||
return rows
|
||||
}
|
||||
byKey := make(map[string]map[string]any, len(spreadRows))
|
||||
for _, row := range spreadRows {
|
||||
key := productPerformanceMapMarketVariantKey(row)
|
||||
if key != "" {
|
||||
byKey[key] = row
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
spread := byKey[productPerformanceMapMarketVariantKey(row)]
|
||||
if spread == nil {
|
||||
continue
|
||||
}
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
for _, prefix := range []string{"__market_keys_", "__customer_keys_"} {
|
||||
field := prefix + suffix
|
||||
if value, ok := spread[field]; ok {
|
||||
row[field] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func productPerformanceMapMarketVariantKey(row map[string]any) string {
|
||||
productCode := normalizeProductPerformanceProductCode(stringFromMap(row, "product_code"))
|
||||
if productCode == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{
|
||||
productCode,
|
||||
strings.TrimSpace(stringFromMap(row, "color_code")),
|
||||
strings.TrimSpace(stringFromMap(row, "yaka_kodu")),
|
||||
displayProductPerformanceMarketName(stringFromMap(row, "market_key")),
|
||||
}, "|")
|
||||
}
|
||||
|
||||
func productPerformanceGroupedSnapshotRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, bool, error) {
|
||||
reportKey, ok := productPerformanceGroupedSnapshotKey(mode)
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
rows, exists, err := loadProductPerformanceSnapshotMapRows(ctx, pg, reportKey, limit)
|
||||
if err != nil || !exists {
|
||||
return rows, exists, err
|
||||
}
|
||||
if strings.TrimSpace(mode) == "idle" {
|
||||
return productPerformanceIdleSourceRows(rows), true, nil
|
||||
}
|
||||
return rows, true, nil
|
||||
}
|
||||
|
||||
func productPerformanceGroupedSnapshotReportKey(mode string, levels []string, mainGroup string) string {
|
||||
levels = sanitizeProductPerformanceGroupLevels(levels)
|
||||
if len(levels) == 0 {
|
||||
levels = defaultProductPerformanceGroupLevels(mode)
|
||||
}
|
||||
parts := []string{"grouped", strings.TrimSpace(mode), productPerformanceGroupedLevelsKey(levels)}
|
||||
if strings.TrimSpace(mainGroup) != "" {
|
||||
parts = append(parts, strings.TrimSpace(mainGroup))
|
||||
}
|
||||
return productPerformanceSnapshotKey(parts...)
|
||||
}
|
||||
|
||||
func productPerformanceGroupedLevelsKey(levels []string) string {
|
||||
levels = sanitizeProductPerformanceGroupLevels(levels)
|
||||
if len(levels) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(levels, ">")
|
||||
}
|
||||
|
||||
func productPerformanceGroupedSnapshotKey(mode string) (string, bool) {
|
||||
switch strings.TrimSpace(mode) {
|
||||
case "products", "product_detail", "idle", "":
|
||||
return productPerformanceSnapshotKey("products"), true
|
||||
case "order_product_customers":
|
||||
return productPerformanceSnapshotKey("order-product-customers"), true
|
||||
case "order_market_details":
|
||||
return productPerformanceSnapshotKey("order-market-details"), true
|
||||
case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product":
|
||||
return productPerformanceSnapshotKey("sales-breakdown", productPerformanceSalesBreakdownMode(mode)), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceSalesBreakdownMode(mode string) string {
|
||||
switch strings.TrimSpace(mode) {
|
||||
case "sales_color_yaka_market_customer":
|
||||
@@ -3640,9 +5501,339 @@ func productPerformanceIdleSourceRows(rows []map[string]any) []map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map[string]any, levels []string, level int, visualLevel int, parentKeys []string, expandedKeys map[string]bool) {
|
||||
type productPerformanceGroupedSnapshotAvgState struct {
|
||||
Weighted float64
|
||||
Weight float64
|
||||
Sum float64
|
||||
Count float64
|
||||
}
|
||||
|
||||
type productPerformanceGroupedSnapshotNode struct {
|
||||
Key string
|
||||
Level int
|
||||
Field string
|
||||
Value string
|
||||
Row map[string]any
|
||||
Count int
|
||||
Children map[string]*productPerformanceGroupedSnapshotNode
|
||||
ChildOrder []string
|
||||
StockMetricSeen map[string]map[string]bool
|
||||
IdleSeen map[string]bool
|
||||
Avg map[string]*productPerformanceGroupedSnapshotAvgState
|
||||
BucketCounts map[string]int
|
||||
Image map[string]any
|
||||
MarketSeen map[string]map[string]bool
|
||||
CustomerSeen map[string]map[string]bool
|
||||
}
|
||||
|
||||
func buildProductPerformanceGroupedSnapshotRows(sourceRows []map[string]any, levels []string, mode string) []map[string]any {
|
||||
roots := map[string]*productPerformanceGroupedSnapshotNode{}
|
||||
rootOrder := make([]string, 0)
|
||||
for _, row := range sourceRows {
|
||||
parentKeys := []string{"tab:" + mode}
|
||||
parentChildren := roots
|
||||
parentOrder := &rootOrder
|
||||
visualLevel := 0
|
||||
for _, field := range levels {
|
||||
value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
|
||||
if shouldSkipProductPerformanceGroupValue(field, value) {
|
||||
continue
|
||||
}
|
||||
keyPart := field + ":" + value
|
||||
key := strings.Join(append(parentKeys, keyPart), "|")
|
||||
node := parentChildren[key]
|
||||
if node == nil {
|
||||
node = &productPerformanceGroupedSnapshotNode{
|
||||
Key: key,
|
||||
Level: visualLevel,
|
||||
Field: field,
|
||||
Value: value,
|
||||
Row: map[string]any{},
|
||||
Children: map[string]*productPerformanceGroupedSnapshotNode{},
|
||||
}
|
||||
parentChildren[key] = node
|
||||
*parentOrder = append(*parentOrder, key)
|
||||
}
|
||||
node.add(row)
|
||||
parentKeys = append(parentKeys, keyPart)
|
||||
parentChildren = node.Children
|
||||
parentOrder = &node.ChildOrder
|
||||
visualLevel++
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]map[string]any, 0, len(sourceRows))
|
||||
appendProductPerformanceGroupedSnapshotNodes(&out, roots, rootOrder)
|
||||
applyProductPerformanceGroupedChildScoreAverages(out, mode)
|
||||
return out
|
||||
}
|
||||
|
||||
func applyProductPerformanceGroupedChildScoreAverages(rows []map[string]any, mode string) {
|
||||
if !shouldCascadeProductPerformanceGroupedScores(mode) || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
byKey := make(map[string]map[string]any, len(rows))
|
||||
children := map[string][]map[string]any{}
|
||||
maxLevel := -1
|
||||
for _, row := range rows {
|
||||
key := stringFromMap(row, "key")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
byKey[key] = row
|
||||
if level := intFromMap(row, "level"); level > maxLevel {
|
||||
maxLevel = level
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
key := stringFromMap(row, "key")
|
||||
parentKey := productPerformanceParentGroupKey(key)
|
||||
if parentKey == "" || byKey[parentKey] == nil {
|
||||
continue
|
||||
}
|
||||
children[parentKey] = append(children[parentKey], row)
|
||||
}
|
||||
for level := maxLevel - 1; level >= 0; level-- {
|
||||
for _, row := range rows {
|
||||
if intFromMap(row, "level") != level {
|
||||
continue
|
||||
}
|
||||
childRows := children[stringFromMap(row, "key")]
|
||||
if len(childRows) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
field := "performance_score_" + suffix
|
||||
if !productPerformanceRowsHaveField(childRows, field) {
|
||||
continue
|
||||
}
|
||||
row[field] = weightedAverageProductPerformanceScoreRows(childRows, field, suffix)
|
||||
}
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
field := "stock_turnover_" + suffix
|
||||
if !productPerformanceRowsHaveField(childRows, field) {
|
||||
continue
|
||||
}
|
||||
row[field] = weightedAverageProductPerformanceRows(childRows, field, "sales_qty_"+suffix)
|
||||
}
|
||||
if score, ok := productPerformanceOptionalFloat(row, "performance_score_90d"); ok {
|
||||
row["performance_score"] = score
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldCascadeProductPerformanceGroupedScores(mode string) bool {
|
||||
switch strings.TrimSpace(mode) {
|
||||
case "products",
|
||||
"product_detail",
|
||||
"idle",
|
||||
"sales_color_yaka_market_customer",
|
||||
"sales_product_country_segment_market_customer",
|
||||
"sales_market_customer_product",
|
||||
"sales_country_segment_market_customer_product":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceRowsHaveField(rows []map[string]any, field string) bool {
|
||||
for _, row := range rows {
|
||||
if _, ok := productPerformanceOptionalFloat(row, field); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) {
|
||||
n.Count++
|
||||
if n.Image == nil && stringFromMap(row, "product_code") != "" {
|
||||
n.Image = row
|
||||
}
|
||||
if bucket := stringFromMap(row, "performance_bucket"); bucket != "" {
|
||||
if n.BucketCounts == nil {
|
||||
n.BucketCounts = map[string]int{}
|
||||
}
|
||||
n.BucketCounts[bucket]++
|
||||
}
|
||||
for key, value := range row {
|
||||
if key == "row_key" || key == "key" || isProductPerformanceInternalGroupField(key) || isProductPerformanceMarginField(key) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case isProductPerformanceDistinctVariantStockMetric(key):
|
||||
n.addDistinctVariantMetric(row, key, value)
|
||||
case key == "idle_cost_usd":
|
||||
variantKey := productPerformanceMapVariantKey(row)
|
||||
if variantKey == "" {
|
||||
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
|
||||
continue
|
||||
}
|
||||
if n.IdleSeen == nil {
|
||||
n.IdleSeen = map[string]bool{}
|
||||
}
|
||||
if !n.IdleSeen[variantKey] {
|
||||
n.IdleSeen[variantKey] = true
|
||||
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
|
||||
}
|
||||
case shouldAverageProductPerformanceField(key):
|
||||
if n.Avg == nil {
|
||||
n.Avg = map[string]*productPerformanceGroupedSnapshotAvgState{}
|
||||
}
|
||||
state := n.Avg[key]
|
||||
if state == nil {
|
||||
state = &productPerformanceGroupedSnapshotAvgState{}
|
||||
n.Avg[key] = state
|
||||
}
|
||||
number := floatFromAny(value)
|
||||
weight := productPerformanceMetricWeight(row, key)
|
||||
if weight > 0 {
|
||||
state.Weighted += number * weight
|
||||
state.Weight += weight
|
||||
}
|
||||
state.Sum += number
|
||||
state.Count++
|
||||
case shouldSumProductPerformanceField(key):
|
||||
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
|
||||
default:
|
||||
if _, ok := n.Row[key]; !ok {
|
||||
n.Row[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
n.addDistinctSpread(row)
|
||||
}
|
||||
|
||||
func (n *productPerformanceGroupedSnapshotNode) addDistinctVariantMetric(row map[string]any, key string, value any) {
|
||||
variantKey := productPerformanceMapVariantKey(row)
|
||||
if variantKey == "" {
|
||||
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
|
||||
return
|
||||
}
|
||||
if n.StockMetricSeen == nil {
|
||||
n.StockMetricSeen = map[string]map[string]bool{}
|
||||
}
|
||||
seen := n.StockMetricSeen[key]
|
||||
if seen == nil {
|
||||
seen = map[string]bool{}
|
||||
n.StockMetricSeen[key] = seen
|
||||
}
|
||||
if seen[variantKey] {
|
||||
return
|
||||
}
|
||||
seen[variantKey] = true
|
||||
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
|
||||
}
|
||||
|
||||
func (n *productPerformanceGroupedSnapshotNode) addDistinctSpread(row map[string]any) {
|
||||
market := displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
|
||||
if market != "" && market != "STOK" {
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
if floatFromMap(row, "sales_usd_"+suffix) > 0 {
|
||||
if n.MarketSeen == nil {
|
||||
n.MarketSeen = map[string]map[string]bool{}
|
||||
}
|
||||
if n.MarketSeen[suffix] == nil {
|
||||
n.MarketSeen[suffix] = map[string]bool{}
|
||||
}
|
||||
n.MarketSeen[suffix][market] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
customer := strings.TrimSpace(stringFromMap(row, "customer_code"))
|
||||
if customer != "" && customer != "-" {
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
if floatFromMap(row, "sales_usd_"+suffix) > 0 {
|
||||
if n.CustomerSeen == nil {
|
||||
n.CustomerSeen = map[string]map[string]bool{}
|
||||
}
|
||||
if n.CustomerSeen[suffix] == nil {
|
||||
n.CustomerSeen[suffix] = map[string]bool{}
|
||||
}
|
||||
n.CustomerSeen[suffix][customer] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
n.MarketSeen = productPerformanceAddSeenStrings(n.MarketSeen, suffix, productPerformanceStringSliceFromMap(row, "__market_keys_"+suffix))
|
||||
n.CustomerSeen = productPerformanceAddSeenStrings(n.CustomerSeen, suffix, productPerformanceStringSliceFromMap(row, "__customer_keys_"+suffix))
|
||||
}
|
||||
}
|
||||
|
||||
func appendProductPerformanceGroupedSnapshotNodes(out *[]map[string]any, nodes map[string]*productPerformanceGroupedSnapshotNode, order []string) {
|
||||
sort.SliceStable(order, func(i, j int) bool {
|
||||
left := nodes[order[i]]
|
||||
right := nodes[order[j]]
|
||||
if left == nil || right == nil {
|
||||
return order[i] < order[j]
|
||||
}
|
||||
return strings.Compare(left.Value, right.Value) < 0
|
||||
})
|
||||
for _, key := range order {
|
||||
node := nodes[key]
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
*out = append(*out, node.snapshotRow())
|
||||
if len(node.Children) > 0 {
|
||||
appendProductPerformanceGroupedSnapshotNodes(out, node.Children, node.ChildOrder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *productPerformanceGroupedSnapshotNode) snapshotRow() map[string]any {
|
||||
row := cloneMap(n.Row)
|
||||
for key, state := range n.Avg {
|
||||
if state == nil {
|
||||
continue
|
||||
}
|
||||
if state.Weight > 0 {
|
||||
row[key] = state.Weighted / state.Weight
|
||||
} else if state.Count > 0 {
|
||||
row[key] = state.Sum / state.Count
|
||||
}
|
||||
}
|
||||
applyProductPerformanceDistinctSpread(row, n.MarketSeen, n.CustomerSeen)
|
||||
deriveProductPerformanceGroupMetrics(row, n.Field)
|
||||
if bucket := n.dominantBucket(); bucket != "" {
|
||||
row["performance_bucket"] = bucket
|
||||
}
|
||||
clearProductPerformanceGroupDimensions(row, n.Field, n.Value)
|
||||
row["__group"] = true
|
||||
row["row_key"] = "group|" + n.Key
|
||||
row["key"] = n.Key
|
||||
row["level"] = n.Level
|
||||
row["group_field"] = n.Field
|
||||
row["group_value"] = n.Value
|
||||
row["label"] = n.Value
|
||||
row["count"] = n.Count
|
||||
row["recommendation"] = productPerformanceGroupRecommendation(row, n.Field, n.Count)
|
||||
image := n.Image
|
||||
if image == nil {
|
||||
image = row
|
||||
}
|
||||
row["image_product_code"] = stringFromMap(image, "product_code")
|
||||
row["image_color_code"] = stringFromMap(image, "color_code")
|
||||
row["image_yaka_kodu"] = stringFromMap(image, "yaka_kodu")
|
||||
return row
|
||||
}
|
||||
|
||||
func (n *productPerformanceGroupedSnapshotNode) dominantBucket() string {
|
||||
best := ""
|
||||
bestCount := 0
|
||||
for bucket, count := range n.BucketCounts {
|
||||
if count > bestCount || (count == bestCount && bucket < best) {
|
||||
best = bucket
|
||||
bestCount = count
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map[string]any, levels []string, level int, visualLevel int, parentKeys []string, expandedKeys map[string]bool, expandThroughLevel int) {
|
||||
if level >= len(levels) {
|
||||
*out = append(*out, sourceRows...)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3664,20 +5855,21 @@ func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map
|
||||
for _, value := range values {
|
||||
groupRows := grouped[value]
|
||||
if shouldSkipProductPerformanceGroupValue(field, value) {
|
||||
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel, parentKeys, expandedKeys)
|
||||
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel, parentKeys, expandedKeys, expandThroughLevel)
|
||||
continue
|
||||
}
|
||||
keyPart := field + ":" + value
|
||||
key := strings.Join(append(parentKeys, keyPart), "|")
|
||||
*out = append(*out, makeProductPerformanceGroupedRow(key, visualLevel, field, value, groupRows))
|
||||
if expandedKeys[key] {
|
||||
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel+1, append(parentKeys, keyPart), expandedKeys)
|
||||
if expandedKeys[key] || level <= expandThroughLevel {
|
||||
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel+1, append(parentKeys, keyPart), expandedKeys, expandThroughLevel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeProductPerformanceGroupedRow(key string, level int, field, value string, rows []map[string]any) map[string]any {
|
||||
row := aggregateProductPerformanceRows(rows, field)
|
||||
clearProductPerformanceGroupDimensions(row, field, value)
|
||||
row["__group"] = true
|
||||
row["row_key"] = "group|" + key
|
||||
row["key"] = key
|
||||
@@ -3686,8 +5878,7 @@ func makeProductPerformanceGroupedRow(key string, level int, field, value string
|
||||
row["group_value"] = value
|
||||
row["label"] = value
|
||||
row["count"] = len(rows)
|
||||
row[field] = value
|
||||
row["recommendation"] = fmt.Sprintf("%d satır", len(rows))
|
||||
row["recommendation"] = productPerformanceGroupRecommendation(row, field, len(rows))
|
||||
|
||||
image := firstProductPerformanceImageSource(rows)
|
||||
row["image_product_code"] = stringFromMap(image, "product_code")
|
||||
@@ -3696,41 +5887,236 @@ func makeProductPerformanceGroupedRow(key string, level int, field, value string
|
||||
return row
|
||||
}
|
||||
|
||||
var productPerformanceGroupDimensionFields = map[string]bool{
|
||||
"product_code": true,
|
||||
"color_yaka": true,
|
||||
"color_code": true,
|
||||
"yaka_kodu": true,
|
||||
"item_description": true,
|
||||
"kategori": true,
|
||||
"askili_yan": true,
|
||||
"urun_ilk_grubu": true,
|
||||
"urun_ana_grubu": true,
|
||||
"urun_alt_grubu": true,
|
||||
"market_key": true,
|
||||
"country": true,
|
||||
"customer_segment": true,
|
||||
"customer_code": true,
|
||||
"customer_name": true,
|
||||
}
|
||||
|
||||
func clearProductPerformanceGroupDimensions(row map[string]any, groupField, groupValue string) {
|
||||
for field := range productPerformanceGroupDimensionFields {
|
||||
row[field] = ""
|
||||
}
|
||||
if productPerformanceGroupDimensionFields[groupField] {
|
||||
row[groupField] = groupValue
|
||||
}
|
||||
}
|
||||
|
||||
func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any {
|
||||
out := map[string]any{}
|
||||
marketSeen := map[string]map[string]bool{}
|
||||
customerSeen := map[string]map[string]bool{}
|
||||
for _, row := range rows {
|
||||
addProductPerformanceDistinctSpread(row, marketSeen, customerSeen)
|
||||
for key, value := range row {
|
||||
if key == "row_key" || key == "key" {
|
||||
if key == "row_key" || key == "key" || isProductPerformanceInternalGroupField(key) {
|
||||
continue
|
||||
}
|
||||
if key == "stock_qty" {
|
||||
out[key] = distinctProductPerformanceVariantStockQty(rows)
|
||||
if isProductPerformanceMarginField(key) {
|
||||
continue
|
||||
}
|
||||
if isProductPerformanceDistinctVariantStockMetric(key) {
|
||||
out[key] = distinctProductPerformanceVariantNumber(rows, key)
|
||||
continue
|
||||
}
|
||||
if key == "idle_cost_usd" {
|
||||
out[key] = distinctProductPerformanceVariantStockCost(rows)
|
||||
continue
|
||||
}
|
||||
if shouldAverageProductPerformanceField(key) {
|
||||
continue
|
||||
}
|
||||
if shouldSumProductPerformanceField(key) {
|
||||
out[key] = floatFromAny(out[key]) + floatFromAny(value)
|
||||
} else if _, ok := out[key]; !ok && !shouldAverageProductPerformanceField(key) {
|
||||
} else if _, ok := out[key]; !ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
for key := range row {
|
||||
if isProductPerformanceInternalGroupField(key) {
|
||||
continue
|
||||
}
|
||||
if isProductPerformanceMarginField(key) {
|
||||
continue
|
||||
}
|
||||
if shouldAverageProductPerformanceField(key) {
|
||||
out[key] = weightedAverageProductPerformanceRows(rows, key, productPerformanceMetricWeightField(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
applyProductPerformanceDistinctSpread(out, marketSeen, customerSeen)
|
||||
deriveProductPerformanceGroupMetrics(out, groupField)
|
||||
out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket")
|
||||
return out
|
||||
}
|
||||
|
||||
func distinctProductPerformanceVariantStockQty(rows []map[string]any) float64 {
|
||||
func addProductPerformanceDistinctSpread(row map[string]any, marketSeen, customerSeen map[string]map[string]bool) {
|
||||
market := displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
|
||||
if market != "" && market != "STOK" {
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
if floatFromMap(row, "sales_usd_"+suffix) > 0 {
|
||||
if marketSeen[suffix] == nil {
|
||||
marketSeen[suffix] = map[string]bool{}
|
||||
}
|
||||
marketSeen[suffix][market] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
customer := strings.TrimSpace(stringFromMap(row, "customer_code"))
|
||||
if customer != "" && customer != "-" {
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
if floatFromMap(row, "sales_usd_"+suffix) > 0 {
|
||||
if customerSeen[suffix] == nil {
|
||||
customerSeen[suffix] = map[string]bool{}
|
||||
}
|
||||
customerSeen[suffix][customer] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
marketSeen = productPerformanceAddSeenStrings(marketSeen, suffix, productPerformanceStringSliceFromMap(row, "__market_keys_"+suffix))
|
||||
customerSeen = productPerformanceAddSeenStrings(customerSeen, suffix, productPerformanceStringSliceFromMap(row, "__customer_keys_"+suffix))
|
||||
}
|
||||
}
|
||||
|
||||
func applyProductPerformanceDistinctSpread(row map[string]any, marketSeen, customerSeen map[string]map[string]bool) {
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
marketField := "market_count_" + suffix
|
||||
customerField := "customer_count_" + suffix
|
||||
if seen := marketSeen[suffix]; len(seen) > 0 {
|
||||
row[marketField] = len(seen)
|
||||
}
|
||||
if seen := customerSeen[suffix]; len(seen) > 0 {
|
||||
row[customerField] = len(seen)
|
||||
}
|
||||
if floatFromMap(row, "sales_usd_"+suffix) > 0 && (suffix == "90d" || suffix == "total") {
|
||||
if intFromMap(row, marketField) == 0 {
|
||||
row[marketField] = 1
|
||||
}
|
||||
if intFromMap(row, customerField) == 0 {
|
||||
if suffix == "total" {
|
||||
row[customerField] = maxInt(1, intFromMap(row, "customer_count_90d"))
|
||||
} else {
|
||||
row[customerField] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceAddSeenStrings(seen map[string]map[string]bool, suffix string, values []string) map[string]map[string]bool {
|
||||
if len(values) == 0 {
|
||||
return seen
|
||||
}
|
||||
if seen == nil {
|
||||
seen = map[string]map[string]bool{}
|
||||
}
|
||||
if seen[suffix] == nil {
|
||||
seen[suffix] = map[string]bool{}
|
||||
}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "-" {
|
||||
continue
|
||||
}
|
||||
seen[suffix][value] = true
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
func productPerformanceStringSliceFromMap(row map[string]any, field string) []string {
|
||||
value, ok := row[field]
|
||||
if !ok || value == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
text := strings.TrimSpace(fmt.Sprint(item))
|
||||
if text != "" {
|
||||
out = append(out, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
text := strings.TrimSpace(v)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
var parsed []string
|
||||
if strings.HasPrefix(text, "[") && json.Unmarshal([]byte(text), &parsed) == nil {
|
||||
return parsed
|
||||
}
|
||||
return []string{text}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isProductPerformanceInternalGroupField(field string) bool {
|
||||
return strings.HasPrefix(field, "__")
|
||||
}
|
||||
|
||||
func productPerformanceGroupRecommendation(row map[string]any, groupField string, count int) string {
|
||||
groupField = strings.TrimSpace(groupField)
|
||||
salesQty90 := floatFromMap(row, "sales_qty_90d")
|
||||
salesUSD90 := floatFromMap(row, "sales_usd_90d")
|
||||
stockQty := floatFromMap(row, "stock_qty")
|
||||
marginCost := floatFromMap(row, "gross_margin_cost_90d")
|
||||
if marginCost == 0 {
|
||||
marginCost = floatFromMap(row, "gross_margin_90d")
|
||||
}
|
||||
switch groupField {
|
||||
case "market_key":
|
||||
if salesQty90 <= 0 && stockQty > 0 {
|
||||
return "Bu piyasada son 90 gunde satis yok"
|
||||
}
|
||||
if marginCost < 0 {
|
||||
return "Bu piyasada ciplak marj negatif"
|
||||
}
|
||||
return fmt.Sprintf("Piyasa satisi %.0f adet / %.0f USD", salesQty90, salesUSD90)
|
||||
case "color_yaka":
|
||||
if salesQty90 <= 0 && stockQty > 0 {
|
||||
return "Renk/yaka stokta, son 90 gun satisi yok"
|
||||
}
|
||||
if marginCost < 0 {
|
||||
return "Renk/yaka ciplak marji negatif"
|
||||
}
|
||||
return fmt.Sprintf("Renk/yaka satisi %.0f adet", salesQty90)
|
||||
default:
|
||||
if recommendation := strings.TrimSpace(stringFromMap(row, "recommendation")); recommendation != "" {
|
||||
return recommendation
|
||||
}
|
||||
return fmt.Sprintf("%d satir", count)
|
||||
}
|
||||
}
|
||||
|
||||
func isProductPerformanceMarginField(field string) bool {
|
||||
return strings.HasPrefix(field, "gross_margin")
|
||||
}
|
||||
|
||||
func isProductPerformanceDistinctVariantStockMetric(field string) bool {
|
||||
return field == "stock_qty" || strings.HasPrefix(field, "avg_stock_")
|
||||
}
|
||||
|
||||
func distinctProductPerformanceVariantNumber(rows []map[string]any, field string) float64 {
|
||||
seen := map[string]bool{}
|
||||
total := 0.0
|
||||
hasKey := false
|
||||
@@ -3744,13 +6130,13 @@ func distinctProductPerformanceVariantStockQty(rows []map[string]any) float64 {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
total += floatFromMap(row, "stock_qty")
|
||||
total += floatFromMap(row, field)
|
||||
}
|
||||
if hasKey {
|
||||
return total
|
||||
}
|
||||
for _, row := range rows {
|
||||
total += floatFromMap(row, "stock_qty")
|
||||
total += floatFromMap(row, field)
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -3791,26 +6177,94 @@ func productPerformanceMapVariantKey(row map[string]any) string {
|
||||
}
|
||||
|
||||
func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) {
|
||||
for _, suffix := range []string{"90d", "180d", "365d", "total"} {
|
||||
normalizeProductPerformanceCostFields(out)
|
||||
isGroup := strings.TrimSpace(groupField) != ""
|
||||
if isGroup {
|
||||
out["group_field"] = groupField
|
||||
}
|
||||
preservedCustomerScores := map[string]float64{}
|
||||
preservedProductScores := map[string]float64{}
|
||||
preservedStockTurnovers := map[string]float64{}
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
if score, ok := productPerformanceOptionalFloat(out, "customer_score_"+suffix); ok {
|
||||
preservedCustomerScores[suffix] = score
|
||||
}
|
||||
if score, ok := productPerformanceOptionalFloat(out, "performance_score_"+suffix); ok {
|
||||
preservedProductScores[suffix] = score
|
||||
}
|
||||
if isGroup {
|
||||
if turnover, ok := productPerformanceOptionalFloat(out, "stock_turnover_"+suffix); ok {
|
||||
preservedStockTurnovers[suffix] = turnover
|
||||
}
|
||||
}
|
||||
}
|
||||
if score, ok := productPerformanceOptionalFloat(out, "performance_score"); ok {
|
||||
if _, exists := preservedProductScores["90d"]; !exists {
|
||||
preservedProductScores["90d"] = score
|
||||
}
|
||||
}
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
sales := floatFromMap(out, "sales_usd_"+suffix)
|
||||
qty := floatFromMap(out, "sales_qty_"+suffix)
|
||||
stockQty := floatFromMap(out, "stock_qty")
|
||||
if _, exists := out["stock_turnover_"+suffix]; !exists && stockQty > 0 {
|
||||
out["stock_turnover_"+suffix] = qty / stockQty
|
||||
turnoverBase := floatFromMap(out, "avg_stock_"+suffix)
|
||||
if turnoverBase <= 0 && !isGroup {
|
||||
turnoverBase = stockQty
|
||||
}
|
||||
days := productPerformancePeriodDays(out, suffix)
|
||||
avgDaily := floatFromMap(out, "avg_daily_sales_"+suffix)
|
||||
if days > 0 {
|
||||
avgDaily = qty / days
|
||||
out["avg_daily_sales_"+suffix] = avgDaily
|
||||
}
|
||||
if avgDaily > 0 && turnoverBase > 0 {
|
||||
out["stock_days_"+suffix] = turnoverBase / avgDaily
|
||||
} else if qty <= 0 && stockQty > 0 {
|
||||
out["stock_days_"+suffix] = 9999
|
||||
} else if _, ok := out["stock_days_"+suffix]; !ok {
|
||||
out["stock_days_"+suffix] = 0
|
||||
}
|
||||
out["stock_turnover_"+suffix] = productPerformanceAnnualizedStockTurnover(qty, turnoverBase, days)
|
||||
if turnover, ok := preservedStockTurnovers[suffix]; ok {
|
||||
out["stock_turnover_"+suffix] = turnover
|
||||
}
|
||||
if qty > 0 {
|
||||
out["avg_price_usd_"+suffix] = sales / qty
|
||||
}
|
||||
basePrice, hasBasePrice := productPerformanceUnitCostForSuffix(out, "base_price_usd", suffix)
|
||||
costPrice, hasCostPrice := productPerformanceUnitCostForSuffix(out, "cost_price_usd", suffix)
|
||||
if hasBasePrice {
|
||||
out["base_price_usd_"+suffix] = basePrice
|
||||
}
|
||||
if hasCostPrice {
|
||||
out["cost_price_usd_"+suffix] = costPrice
|
||||
}
|
||||
if qty > 0 && hasBasePrice {
|
||||
avgPrice := sales / qty
|
||||
out["unit_profit_base_"+suffix] = avgPrice - basePrice
|
||||
out["gross_profit_base_usd_"+suffix] = sales - (qty * basePrice)
|
||||
} else {
|
||||
out["unit_profit_base_"+suffix] = 0
|
||||
out["gross_profit_base_usd_"+suffix] = 0
|
||||
}
|
||||
if qty > 0 && hasCostPrice {
|
||||
avgPrice := sales / qty
|
||||
out["unit_profit_cost_"+suffix] = avgPrice - costPrice
|
||||
out["gross_profit_cost_usd_"+suffix] = sales - (qty * costPrice)
|
||||
out["gross_profit_usd_"+suffix] = out["gross_profit_cost_usd_"+suffix]
|
||||
} else {
|
||||
out["unit_profit_cost_"+suffix] = 0
|
||||
out["gross_profit_cost_usd_"+suffix] = 0
|
||||
out["gross_profit_usd_"+suffix] = 0
|
||||
}
|
||||
if sales > 0 {
|
||||
if _, ok := out["gross_profit_base_usd_"+suffix]; ok {
|
||||
out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales
|
||||
}
|
||||
if _, ok := out["gross_profit_cost_usd_"+suffix]; ok {
|
||||
out["gross_margin_cost_"+suffix] = floatFromMap(out, "gross_profit_cost_usd_"+suffix) / sales
|
||||
}
|
||||
if _, ok := out["gross_profit_usd_"+suffix]; ok {
|
||||
out["gross_margin_"+suffix] = floatFromMap(out, "gross_profit_usd_"+suffix) / sales
|
||||
}
|
||||
out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales
|
||||
out["gross_margin_cost_"+suffix] = floatFromMap(out, "gross_profit_cost_usd_"+suffix) / sales
|
||||
out["gross_margin_"+suffix] = floatFromMap(out, "gross_profit_usd_"+suffix) / sales
|
||||
} else {
|
||||
out["gross_margin_base_"+suffix] = 0
|
||||
out["gross_margin_cost_"+suffix] = 0
|
||||
out["gross_margin_"+suffix] = 0
|
||||
}
|
||||
}
|
||||
orderUSD := floatFromMap(out, "order_usd")
|
||||
@@ -3825,11 +6279,131 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string)
|
||||
if _, ok := out["net_stock_after_order"]; ok || orderQty > 0 {
|
||||
out["net_stock_after_order"] = floatFromMap(out, "stock_qty") - orderQty
|
||||
}
|
||||
out["customer_score_90d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "90d"))
|
||||
out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d"))
|
||||
out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d"))
|
||||
out["customer_score_total"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "total"))
|
||||
out["performance_score"] = productPerformanceGroupScore(out, groupField)
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
if score, ok := preservedCustomerScores[suffix]; ok {
|
||||
out["customer_score_"+suffix] = score
|
||||
} else {
|
||||
out["customer_score_"+suffix] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, suffix))
|
||||
}
|
||||
if score, ok := preservedProductScores[suffix]; ok {
|
||||
out["performance_score_"+suffix] = score
|
||||
} else {
|
||||
out["performance_score_"+suffix] = productPerformanceSalesPeriodScore(out, suffix)
|
||||
}
|
||||
}
|
||||
out["performance_score"] = out["performance_score_90d"]
|
||||
if floatFromMap(out, "order_qty") > 0 || floatFromMap(out, "order_usd") > 0 {
|
||||
score := productPerformanceOrderGroupScore(out)
|
||||
out["performance_score_90d"] = score
|
||||
out["performance_score_180d"] = score
|
||||
out["performance_score_365d"] = score
|
||||
out["performance_score_total"] = score
|
||||
out["performance_score"] = score
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProductPerformanceCostFields(row map[string]any) {
|
||||
normalize := func(costField, baseField string) {
|
||||
cost, hasCost := productPerformanceOptionalFloat(row, costField)
|
||||
base, hasBase := productPerformanceOptionalFloat(row, baseField)
|
||||
if !hasCost && !hasBase {
|
||||
return
|
||||
}
|
||||
cost, base = normalizeProductPerformanceCostPair(cost, base)
|
||||
if hasCost || cost > 0 {
|
||||
row[costField] = cost
|
||||
}
|
||||
if hasBase || base > 0 {
|
||||
row[baseField] = base
|
||||
}
|
||||
}
|
||||
normalize("cost_price_usd", "base_price_usd")
|
||||
for _, suffix := range productPerformancePeriodSuffixes() {
|
||||
normalize("cost_price_usd_"+suffix, "base_price_usd_"+suffix)
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformancePeriodSuffixes() []string {
|
||||
return []string{"90d", "180d", "365d", "total"}
|
||||
}
|
||||
|
||||
func productPerformancePeriodDays(row map[string]any, suffix string) float64 {
|
||||
switch suffix {
|
||||
case "90d":
|
||||
return 90
|
||||
case "180d":
|
||||
return 180
|
||||
case "365d":
|
||||
return 360
|
||||
case "total":
|
||||
start := parseProductPerformanceDate(stringFromMap(row, "period_start"))
|
||||
if start.IsZero() {
|
||||
start = time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
end := parseProductPerformanceDate(stringFromMap(row, "period_end"))
|
||||
if end.IsZero() {
|
||||
end = parseProductPerformanceDate(stringFromMap(row, "kpi_date"))
|
||||
}
|
||||
if end.IsZero() || end.Before(start) {
|
||||
return 0
|
||||
}
|
||||
return end.Sub(start).Hours()/24 + 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const productPerformanceStockTurnoverYearDays = 360.0
|
||||
|
||||
func productPerformanceAnnualizedStockTurnover(salesQty, avgStock, periodDays float64) float64 {
|
||||
if salesQty <= 0 || avgStock <= 0 {
|
||||
return 0
|
||||
}
|
||||
raw := salesQty / avgStock
|
||||
if periodDays <= 0 {
|
||||
return raw
|
||||
}
|
||||
return raw * productPerformanceStockTurnoverYearDays / periodDays
|
||||
}
|
||||
|
||||
func parseProductPerformanceDate(value string) time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if len(value) >= len("2006-01-02") {
|
||||
value = value[:len("2006-01-02")]
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", value)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func productPerformanceOptionalFloat(row map[string]any, field string) (float64, bool) {
|
||||
value, ok := row[field]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return floatFromAny(value), true
|
||||
}
|
||||
|
||||
func productPerformanceUnitCostForSuffix(row map[string]any, baseField, suffix string) (float64, bool) {
|
||||
hasSuffix := false
|
||||
if value, ok := row[baseField+"_"+suffix]; ok {
|
||||
hasSuffix = true
|
||||
if n := floatFromAny(value); n > 0 {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
if value, ok := row[baseField]; ok {
|
||||
return floatFromAny(value), true
|
||||
}
|
||||
if hasSuffix {
|
||||
return 0, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func productPerformanceGroupScore(row map[string]any, groupField string) float64 {
|
||||
@@ -3852,22 +6426,40 @@ func isProductPerformanceCustomerGroup(groupField string) bool {
|
||||
}
|
||||
|
||||
func productPerformanceSalesGroupScore(row map[string]any) float64 {
|
||||
salesIndex := floatFromMap(row, "sales_index_90d")
|
||||
margin := floatFromMap(row, "gross_margin_cost_90d")
|
||||
invoiceCount := floatFromMap(row, "invoice_count_90d")
|
||||
salesQty := floatFromMap(row, "sales_qty_90d")
|
||||
stockTurnover := floatFromMap(row, "stock_turnover_90d")
|
||||
return productPerformanceSalesPeriodScore(row, "90d")
|
||||
}
|
||||
|
||||
func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float64 {
|
||||
salesUSD := floatFromMap(row, "sales_usd_"+suffix)
|
||||
salesIndex := floatFromMap(row, "sales_index_"+suffix)
|
||||
if salesIndex <= 0 && suffix == "total" {
|
||||
salesIndex = floatFromMap(row, "sales_index_total")
|
||||
}
|
||||
margin := floatFromMap(row, "gross_margin_cost_"+suffix)
|
||||
if margin == 0 {
|
||||
margin = floatFromMap(row, "gross_margin_"+suffix)
|
||||
}
|
||||
salesQty := floatFromMap(row, "sales_qty_"+suffix)
|
||||
stockTurnover := floatFromMap(row, "stock_turnover_"+suffix)
|
||||
if stockTurnover == 0 {
|
||||
if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 {
|
||||
stockTurnover = salesQty / stockQty
|
||||
avgStock := floatFromMap(row, "avg_stock_"+suffix)
|
||||
if avgStock <= 0 && strings.TrimSpace(stringFromMap(row, "group_field")) == "" {
|
||||
avgStock = floatFromMap(row, "stock_qty")
|
||||
}
|
||||
if avgStock > 0 {
|
||||
stockTurnover = productPerformanceAnnualizedStockTurnover(salesQty, avgStock, productPerformancePeriodDays(row, suffix))
|
||||
}
|
||||
}
|
||||
score := minFloat(35, maxFloat(0, salesIndex)*18) +
|
||||
minFloat(30, maxFloat(0, margin)*60) +
|
||||
minFloat(15, invoiceCount*1.5) +
|
||||
minFloat(10, salesQty/10) +
|
||||
minFloat(10, maxFloat(0, stockTurnover)*5)
|
||||
return score
|
||||
return productPerformanceProductScore(
|
||||
suffix,
|
||||
salesUSD,
|
||||
salesIndex,
|
||||
margin,
|
||||
stockTurnover,
|
||||
productPerformancePeriodCount(row, "market_count", suffix),
|
||||
productPerformancePeriodCount(row, "customer_count", suffix),
|
||||
productPerformancePeriodDays(row, suffix),
|
||||
)
|
||||
}
|
||||
|
||||
func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 {
|
||||
@@ -3895,24 +6487,208 @@ func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 {
|
||||
func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 {
|
||||
salesUSD := floatFromMap(row, "sales_usd")
|
||||
margin := floatFromMap(row, "gross_margin_cost")
|
||||
invoiceCount := floatFromMap(row, "invoice_count")
|
||||
if margin == 0 {
|
||||
margin = floatFromMap(row, "gross_margin")
|
||||
}
|
||||
salesQty := floatFromMap(row, "sales_qty")
|
||||
productCount := floatFromMap(row, "product_count")
|
||||
return minFloat(35, salesUSD/1000) +
|
||||
minFloat(25, maxFloat(0, margin)*55) +
|
||||
minFloat(20, invoiceCount*2) +
|
||||
minFloat(10, salesQty/10) +
|
||||
minFloat(10, productCount)
|
||||
if groupCount := floatFromMap(row, "product_group_count"); groupCount > 0 {
|
||||
productCount = groupCount
|
||||
}
|
||||
suffix := strings.TrimSpace(stringFromMap(row, "suffix"))
|
||||
if suffix == "" {
|
||||
suffix = "90d"
|
||||
}
|
||||
return productPerformanceCustomerScore(suffix, salesUSD, margin, productCount, salesQty)
|
||||
}
|
||||
|
||||
func productPerformanceProductScore(suffix string, salesUSD, salesIndex, margin, stockTurnover, marketCount, customerCount float64, periodDays ...float64) float64 {
|
||||
if salesUSD <= 0 {
|
||||
return 1
|
||||
}
|
||||
days := productPerformanceScorePeriodDays(suffix, periodDays...)
|
||||
revenueScore := productPerformanceRevenueScore(suffix, salesUSD, salesIndex, productPerformanceProductRevenueTargetForDays(suffix, days))
|
||||
score := 0.30*productPerformanceMarginComponentScore(margin) +
|
||||
0.20*productPerformanceRatioScore(stockTurnover, productPerformanceStockTurnoverTarget(suffix)) +
|
||||
0.20*revenueScore +
|
||||
0.05*productPerformanceRatioScore(marketCount, productPerformanceMarketSpreadTarget(suffix)) +
|
||||
0.25*productPerformanceRatioScore(customerCount, productPerformanceCustomerSpreadTargetForDays(suffix, days))
|
||||
return productPerformanceRoundScore(score)
|
||||
}
|
||||
|
||||
func productPerformanceStockTurnoverTarget(suffix string) float64 {
|
||||
return 4
|
||||
}
|
||||
|
||||
func productPerformanceMarketSpreadTarget(suffix string) float64 {
|
||||
switch suffix {
|
||||
case "90d":
|
||||
return 3
|
||||
case "180d":
|
||||
return 3
|
||||
case "365d":
|
||||
return 6
|
||||
default:
|
||||
return 6
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceCustomerSpreadTarget(suffix string) float64 {
|
||||
return productPerformanceCustomerSpreadTargetForDays(suffix, productPerformanceScorePeriodDays(suffix))
|
||||
}
|
||||
|
||||
func productPerformanceCustomerSpreadTargetForDays(suffix string, periodDays float64) float64 {
|
||||
switch suffix {
|
||||
case "90d":
|
||||
return 8
|
||||
case "180d":
|
||||
return 20
|
||||
case "365d":
|
||||
return 50
|
||||
default:
|
||||
return 20 * productPerformanceTotalPeriodMultiplier(periodDays)
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceCustomerScore(suffix string, salesUSD, margin, productGroupCount, salesQty float64) float64 {
|
||||
if salesUSD <= 0 {
|
||||
return 1
|
||||
}
|
||||
score := 0.35*productPerformanceRatioScore(salesUSD, productPerformanceCustomerRevenueTarget(suffix)) +
|
||||
0.30*productPerformanceMarginComponentScore(margin) +
|
||||
0.20*productPerformanceRatioScore(productGroupCount, 8) +
|
||||
0.15*productPerformanceRatioScore(salesQty, productPerformanceCustomerQtyTarget(suffix))
|
||||
return productPerformanceRoundScore(score)
|
||||
}
|
||||
|
||||
func productPerformanceRevenueScore(suffix string, salesUSD, salesIndex, absoluteTarget float64) float64 {
|
||||
return productPerformanceRatioScore(salesUSD, absoluteTarget)
|
||||
}
|
||||
|
||||
func productPerformanceRelativeIndex(value, average float64) float64 {
|
||||
if value <= 0 || average <= 0 {
|
||||
return 0
|
||||
}
|
||||
return value / average
|
||||
}
|
||||
|
||||
func productPerformanceMarginComponentScore(margin float64) float64 {
|
||||
return productPerformanceRatioScore(maxFloat(0, margin), 0.45)
|
||||
}
|
||||
|
||||
func productPerformanceRatioScore(value, target float64) float64 {
|
||||
if target <= 0 || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
return minFloat(100, maxFloat(0, value)*100/target)
|
||||
}
|
||||
|
||||
func productPerformanceRoundScore(score float64) float64 {
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
return math.Round(score*10000) / 10000
|
||||
}
|
||||
|
||||
func productPerformanceProductRevenueTarget(suffix string) float64 {
|
||||
return productPerformanceProductRevenueTargetForDays(suffix, productPerformanceScorePeriodDays(suffix))
|
||||
}
|
||||
|
||||
func productPerformanceProductRevenueTargetForDays(suffix string, periodDays float64) float64 {
|
||||
switch suffix {
|
||||
case "180d":
|
||||
return 50000
|
||||
case "365d":
|
||||
return 100000
|
||||
case "total":
|
||||
return 50000 * productPerformanceTotalPeriodMultiplier(periodDays)
|
||||
default:
|
||||
return 25000
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceScorePeriodDays(suffix string, periodDays ...float64) float64 {
|
||||
if len(periodDays) > 0 && periodDays[0] > 0 {
|
||||
return periodDays[0]
|
||||
}
|
||||
switch suffix {
|
||||
case "90d":
|
||||
return 90
|
||||
case "180d":
|
||||
return 180
|
||||
case "365d":
|
||||
return 360
|
||||
case "total":
|
||||
return productPerformancePeriodDays(map[string]any{
|
||||
"kpi_date": time.Now().Format("2006-01-02"),
|
||||
}, "total")
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceTotalPeriodMultiplier(periodDays float64) float64 {
|
||||
if periodDays <= 0 {
|
||||
periodDays = 180
|
||||
}
|
||||
return maxFloat(1, periodDays/180.0)
|
||||
}
|
||||
|
||||
func productPerformanceCustomerRevenueTarget(suffix string) float64 {
|
||||
switch suffix {
|
||||
case "180d":
|
||||
return 50000
|
||||
case "365d":
|
||||
return 100000
|
||||
case "total":
|
||||
return 300000
|
||||
default:
|
||||
return 25000
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceCustomerQtyTarget(suffix string) float64 {
|
||||
switch suffix {
|
||||
case "180d":
|
||||
return 1000
|
||||
case "365d":
|
||||
return 2000
|
||||
case "total":
|
||||
return 6000
|
||||
default:
|
||||
return 500
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformancePeriodCount(row map[string]any, prefix, suffix string) float64 {
|
||||
if value, ok := row[prefix+"_"+suffix]; ok {
|
||||
if n := floatFromAny(value); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if suffix == "total" {
|
||||
if value, ok := row[prefix+"_total"]; ok {
|
||||
return floatFromAny(value)
|
||||
}
|
||||
}
|
||||
if value, ok := row[prefix+"_90d"]; ok {
|
||||
return floatFromAny(value)
|
||||
}
|
||||
return floatFromAny(row[prefix])
|
||||
}
|
||||
|
||||
func rowPeriodMetricMap(row map[string]any, suffix string) map[string]any {
|
||||
return map[string]any{
|
||||
"sales_usd": floatFromMap(row, "sales_usd_"+suffix),
|
||||
"gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix),
|
||||
"gross_margin": floatFromMap(row, "gross_margin_"+suffix),
|
||||
"invoice_count": floatFromMap(row, "invoice_count_"+suffix),
|
||||
"sales_qty": floatFromMap(row, "sales_qty_"+suffix),
|
||||
"product_count": floatFromMap(row, "product_count"),
|
||||
"suffix": suffix,
|
||||
"sales_usd": floatFromMap(row, "sales_usd_"+suffix),
|
||||
"gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix),
|
||||
"gross_margin": floatFromMap(row, "gross_margin_"+suffix),
|
||||
"sales_qty": floatFromMap(row, "sales_qty_"+suffix),
|
||||
"product_group_count": productPerformancePeriodCount(row, "product_group_count", suffix),
|
||||
"product_count": floatFromMap(row, "product_count"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3956,6 +6732,9 @@ func sanitizeProductPerformanceGroupLevels(levels []string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, level := range levels {
|
||||
level = strings.TrimSpace(level)
|
||||
if level == "color_code" || level == "yaka_kodu" {
|
||||
level = "color_yaka"
|
||||
}
|
||||
if allowed[level] && !seen[level] {
|
||||
out = append(out, level)
|
||||
seen[level] = true
|
||||
@@ -3967,23 +6746,23 @@ func sanitizeProductPerformanceGroupLevels(levels []string) []string {
|
||||
func defaultProductPerformanceGroupLevels(mode string) []string {
|
||||
switch mode {
|
||||
case "sales_color_yaka_market_customer":
|
||||
return []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "country", "market_key", "customer_segment", "customer_code", "customer_name"}
|
||||
return []string{"urun_ana_grubu", "color_yaka", "market_key", "customer_code", "customer_name", "urun_alt_grubu", "product_code"}
|
||||
case "product_detail":
|
||||
return []string{"urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka", "market_key"}
|
||||
return []string{"urun_alt_grubu", "product_code", "color_yaka", "market_key"}
|
||||
case "idle":
|
||||
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
||||
case "sales_product_country_segment_market_customer":
|
||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "country", "customer_segment", "market_key", "customer_code", "customer_name"}
|
||||
case "sales_market_customer_product":
|
||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
||||
case "sales_country_segment_market_customer_product":
|
||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
return []string{"country", "customer_segment", "market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
||||
case "order_product_customers":
|
||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key", "customer_code", "customer_name"}
|
||||
case "order_market_details":
|
||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
||||
default:
|
||||
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu", "market_key"}
|
||||
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4008,7 +6787,16 @@ func mapGroupValue(row map[string]any, field string) string {
|
||||
}
|
||||
|
||||
func shouldSumProductPerformanceField(field string) bool {
|
||||
return strings.HasSuffix(field, "_qty") ||
|
||||
return strings.HasPrefix(field, "sales_qty_") ||
|
||||
strings.HasPrefix(field, "sales_usd_") ||
|
||||
strings.HasPrefix(field, "avg_daily_sales_") ||
|
||||
strings.HasPrefix(field, "gross_profit_") ||
|
||||
strings.HasPrefix(field, "invoice_count_") ||
|
||||
strings.HasPrefix(field, "market_count_") ||
|
||||
strings.HasPrefix(field, "customer_count_") ||
|
||||
strings.HasPrefix(field, "product_group_count_") ||
|
||||
strings.HasPrefix(field, "product_count_") ||
|
||||
strings.HasSuffix(field, "_qty") ||
|
||||
strings.HasSuffix(field, "_usd") ||
|
||||
strings.HasSuffix(field, "_count") ||
|
||||
strings.HasSuffix(field, "_value_usd") ||
|
||||
@@ -4025,6 +6813,9 @@ func shouldSumProductPerformanceField(field string) bool {
|
||||
}
|
||||
|
||||
func shouldAverageProductPerformanceField(field string) bool {
|
||||
if strings.HasPrefix(field, "avg_daily_sales_") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(field, "avg_") ||
|
||||
strings.HasPrefix(field, "unit_") ||
|
||||
strings.HasPrefix(field, "base_price") ||
|
||||
@@ -4033,11 +6824,18 @@ func shouldAverageProductPerformanceField(field string) bool {
|
||||
strings.HasPrefix(field, "expected_margin") ||
|
||||
strings.HasPrefix(field, "sales_index") ||
|
||||
strings.HasPrefix(field, "performance_score") ||
|
||||
strings.HasPrefix(field, "customer_score") ||
|
||||
strings.HasPrefix(field, "stock_days") ||
|
||||
strings.HasPrefix(field, "stock_turnover")
|
||||
}
|
||||
|
||||
func productPerformanceMetricWeightField(field string) string {
|
||||
if productPerformanceUsesCostWeight(field) {
|
||||
return "__product_cost_weight"
|
||||
}
|
||||
if strings.HasPrefix(field, "stock_turnover") {
|
||||
return "sales_qty_" + productPerformanceScoreSuffix(field)
|
||||
}
|
||||
if strings.Contains(field, "_180d") {
|
||||
return "sales_qty_180d"
|
||||
}
|
||||
@@ -4056,11 +6854,75 @@ func productPerformanceMetricWeightField(field string) string {
|
||||
return "sales_qty_90d"
|
||||
}
|
||||
|
||||
func productPerformanceUsesCostWeight(field string) bool {
|
||||
if !(strings.HasPrefix(field, "base_price") || strings.HasPrefix(field, "cost_price")) {
|
||||
return false
|
||||
}
|
||||
return !strings.Contains(field, "_90d") &&
|
||||
!strings.Contains(field, "_180d") &&
|
||||
!strings.Contains(field, "_365d") &&
|
||||
!strings.Contains(field, "_total")
|
||||
}
|
||||
|
||||
func productPerformanceMetricWeight(row map[string]any, field string) float64 {
|
||||
if productPerformanceUsesScoreWeight(field) {
|
||||
return productPerformanceScoreWeight(row, productPerformanceScoreSuffix(field))
|
||||
}
|
||||
weightField := productPerformanceMetricWeightField(field)
|
||||
if weightField == "__product_cost_weight" {
|
||||
return productPerformanceCostWeight(row)
|
||||
}
|
||||
return floatFromMap(row, weightField)
|
||||
}
|
||||
|
||||
func productPerformanceUsesScoreWeight(field string) bool {
|
||||
return strings.HasPrefix(field, "performance_score") ||
|
||||
strings.HasPrefix(field, "customer_score")
|
||||
}
|
||||
|
||||
func productPerformanceScoreSuffix(field string) string {
|
||||
switch {
|
||||
case strings.Contains(field, "_180d"):
|
||||
return "180d"
|
||||
case strings.Contains(field, "_365d"):
|
||||
return "365d"
|
||||
case strings.Contains(field, "_total"):
|
||||
return "total"
|
||||
default:
|
||||
return "90d"
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceScoreWeight(row map[string]any, suffix string) float64 {
|
||||
if weight := floatFromMap(row, "sales_qty_"+suffix); weight > 0 {
|
||||
return weight
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func productPerformanceCostWeight(row map[string]any) float64 {
|
||||
if weight := floatFromMap(row, "sales_qty_total"); weight > 0 {
|
||||
return weight
|
||||
}
|
||||
for _, field := range []string{"sales_qty_365d", "sales_qty_180d", "sales_qty_90d"} {
|
||||
if weight := floatFromMap(row, field); weight > 0 {
|
||||
return weight
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qtyField string) float64 {
|
||||
if productPerformanceUsesScoreWeight(valueField) {
|
||||
return weightedAverageProductPerformanceScoreRows(rows, valueField, productPerformanceScoreSuffix(valueField))
|
||||
}
|
||||
var weighted, qty float64
|
||||
for _, row := range rows {
|
||||
value := floatFromMap(row, valueField)
|
||||
weight := floatFromMap(row, qtyField)
|
||||
if qtyField == "__product_cost_weight" {
|
||||
weight = productPerformanceCostWeight(row)
|
||||
}
|
||||
if weight > 0 {
|
||||
weighted += value * weight
|
||||
qty += weight
|
||||
@@ -4069,9 +6931,74 @@ func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qt
|
||||
if qty > 0 {
|
||||
return weighted / qty
|
||||
}
|
||||
if qtyField == "__product_cost_weight" {
|
||||
return averageProductPerformanceDistinctVariantField(rows, valueField)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func weightedAverageProductPerformanceScoreRows(rows []map[string]any, valueField, suffix string) float64 {
|
||||
var weighted, weight, sum, count float64
|
||||
for _, row := range rows {
|
||||
value, ok := productPerformanceOptionalFloat(row, valueField)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rowWeight := productPerformanceScoreWeight(row, suffix)
|
||||
if rowWeight > 0 {
|
||||
weighted += value * rowWeight
|
||||
weight += rowWeight
|
||||
}
|
||||
sum += value
|
||||
count++
|
||||
}
|
||||
if weight > 0 {
|
||||
return weighted / weight
|
||||
}
|
||||
if count > 0 {
|
||||
return sum / count
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func averageProductPerformanceDistinctVariantField(rows []map[string]any, valueField string) float64 {
|
||||
seen := map[string]bool{}
|
||||
sum := 0.0
|
||||
count := 0.0
|
||||
hasKey := false
|
||||
for _, row := range rows {
|
||||
key := productPerformanceMapVariantKey(row)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
hasKey = true
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
value := floatFromMap(row, valueField)
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
sum += value
|
||||
count++
|
||||
}
|
||||
if !hasKey {
|
||||
for _, row := range rows {
|
||||
value := floatFromMap(row, valueField)
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
sum += value
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count <= 0 {
|
||||
return 0
|
||||
}
|
||||
return sum / count
|
||||
}
|
||||
|
||||
func dominantProductPerformanceValue(rows []map[string]any, field string) string {
|
||||
counts := map[string]int{}
|
||||
for _, row := range rows {
|
||||
@@ -4154,9 +7081,7 @@ func cleanProductPerformanceFirstGroup(value string) string {
|
||||
if value == "-" {
|
||||
return ""
|
||||
}
|
||||
normalized := strings.ToUpper(value)
|
||||
normalized = strings.NewReplacer("İ", "I", "Ş", "S", "Ğ", "G", "Ü", "U", "Ö", "O", "Ç", "C").Replace(normalized)
|
||||
switch normalized {
|
||||
switch normalizeProductPerformanceTurkishText(value) {
|
||||
case "YETISKIN", "YETISKIN/GARSON", "GARSON":
|
||||
return ""
|
||||
default:
|
||||
@@ -4164,6 +7089,24 @@ func cleanProductPerformanceFirstGroup(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProductPerformanceTurkishText(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
return strings.NewReplacer(
|
||||
"\u0130", "I",
|
||||
"\u015E", "S",
|
||||
"\u011E", "G",
|
||||
"\u00DC", "U",
|
||||
"\u00D6", "O",
|
||||
"\u00C7", "C",
|
||||
"\u0131", "I",
|
||||
"\u015F", "S",
|
||||
"\u011F", "G",
|
||||
"\u00FC", "U",
|
||||
"\u00F6", "O",
|
||||
"\u00E7", "C",
|
||||
).Replace(value)
|
||||
}
|
||||
|
||||
func cleanProductPerformanceOptionalAttr(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "-" {
|
||||
@@ -4176,6 +7119,10 @@ func floatFromMap(row map[string]any, field string) float64 {
|
||||
return floatFromAny(row[field])
|
||||
}
|
||||
|
||||
func intFromMap(row map[string]any, field string) int {
|
||||
return int(floatFromAny(row[field]))
|
||||
}
|
||||
|
||||
func floatFromAny(value any) float64 {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
@@ -4194,6 +7141,20 @@ func floatFromAny(value any) float64 {
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
@@ -4214,7 +7175,7 @@ FROM mk_product_performance_sales_daily
|
||||
WHERE product_code=$1
|
||||
AND color_code=$2
|
||||
AND yaka_kodu=$3
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
ORDER BY sales_usd DESC, sales_date DESC
|
||||
LIMIT $4
|
||||
`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit)
|
||||
@@ -4374,6 +7335,16 @@ func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) {
|
||||
return strings.Join(parts, ""), args
|
||||
}
|
||||
|
||||
func productPerformanceHasServerFilters(f ProductPerformanceFilters) bool {
|
||||
return strings.TrimSpace(f.Search) != "" ||
|
||||
strings.TrimSpace(f.ProductCode) != "" ||
|
||||
strings.TrimSpace(f.MarketKey) != "" ||
|
||||
strings.TrimSpace(f.Kategori) != "" ||
|
||||
strings.TrimSpace(f.Seri) != "" ||
|
||||
strings.TrimSpace(f.Bucket) != "" ||
|
||||
f.Page > 1
|
||||
}
|
||||
|
||||
func productPerformanceOrderBy(sortBy string, desc bool) string {
|
||||
allowed := map[string]string{
|
||||
"product_code": "product_code",
|
||||
@@ -4455,21 +7426,30 @@ func productPerformanceVariantKey(productCode, colorCode, yakaKodu string) strin
|
||||
}
|
||||
|
||||
func normalizeProductPerformanceCostPair(costPriceUSD, basePriceUSD float64) (float64, float64) {
|
||||
if costPriceUSD > 0 && basePriceUSD > 0 && costPriceUSD < basePriceUSD {
|
||||
if costPriceUSD > 0 && basePriceUSD > 0 && costPriceUSD > basePriceUSD {
|
||||
return basePriceUSD, costPriceUSD
|
||||
}
|
||||
return costPriceUSD, basePriceUSD
|
||||
}
|
||||
|
||||
const productPerformanceExcludedFirstGroupsSQL = "('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')"
|
||||
const productPerformanceTurkishTranslateSQL = "U&'\\0130\\015E\\011E\\00DC\\00D6\\00C7\\0131\\015F\\011F\\00FC\\00F6\\00E7', 'ISGUOCisguoc'"
|
||||
const productPerformanceExcludedFirstGroupsSQL = "('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')"
|
||||
|
||||
func productPerformanceNormalizedTextSQL(expr string) string {
|
||||
return "upper(translate(btrim(COALESCE(" + expr + ",'')), " + productPerformanceTurkishTranslateSQL + "))"
|
||||
}
|
||||
|
||||
func productPerformanceCleanFirstGroupSQL(expr string) string {
|
||||
return "CASE WHEN btrim(COALESCE(" + expr + ",'')) = '-' THEN '' WHEN " + productPerformanceNormalizedTextSQL(expr) + " IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE COALESCE(" + expr + ",'') END"
|
||||
}
|
||||
|
||||
func productPerformanceAllowedFirstGroupSQL(expr string) string {
|
||||
return "upper(btrim(COALESCE(" + expr + ",''))) NOT IN " + productPerformanceExcludedFirstGroupsSQL
|
||||
return productPerformanceNormalizedTextSQL(expr) + " NOT IN " + productPerformanceExcludedFirstGroupsSQL
|
||||
}
|
||||
|
||||
func isExcludedProductPerformanceFirstGroup(value string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "MALZEMELI FASON", "MALZEMESIZ FASON", "DIGER":
|
||||
switch normalizeProductPerformanceTurkishText(value) {
|
||||
case "MALZEMELI FASON", "MALZEMESIZ FASON", "MAZLEMELI FASON", "MAZEMESIZ FASON", "DIGER":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -4538,7 +7518,7 @@ SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT kpi_date FROM Latest)
|
||||
AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1)
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
|
||||
`, pq.Array(variantKeys))
|
||||
if err != nil {
|
||||
@@ -4594,68 +7574,68 @@ GROUP BY product_code, color_code, yaka_kodu
|
||||
func productPerformanceOrderRecommendation(row models.ProductPerformanceOrderAnalysisRow) (string, string) {
|
||||
switch {
|
||||
case row.OrderQty > 0 && row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
|
||||
return "STOKSUZ_TALEP", "Açık sipariş stoktan büyük. Karlı talep var; üretim/satın alma önceliği ver."
|
||||
return "STOKSUZ_TALEP", "Acik siparis stoktan buyuk. Karli talep var; uretim/satin alma onceligi ver."
|
||||
case row.ExpectedProfitCostUSD < 0:
|
||||
return "FIYAT_BASKISI", "Açık sipariş çıplak maliyete göre zarar yazıyor. Fiyat/maliyet kontrol edilmeli."
|
||||
return "FIYAT_BASKISI", "Acik siparis ciplak maliyete gore zarar yaziyor. Fiyat/maliyet kontrol edilmeli."
|
||||
case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder >= 0:
|
||||
return "YILDIZ_URUN", "Açık sipariş karlı ve stok karşılıyor. Teslimat korunmalı."
|
||||
return "YILDIZ_URUN", "Acik siparis karli ve stok karsiliyor. Teslimat korunmali."
|
||||
case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder < 0:
|
||||
return "FIYAT_FIRSATI", "Karlı talep var ama stok yetersiz. Üretim planına alınmalı."
|
||||
return "FIYAT_FIRSATI", "Karli talep var ama stok yetersiz. Uretim planina alinmali."
|
||||
case row.OverdueQty > 0:
|
||||
return "TAKIP", "Termin gecikmesi olan açık sipariş var. Operasyon takibi gerekli."
|
||||
return "TAKIP", "Termin gecikmesi olan acik siparis var. Operasyon takibi gerekli."
|
||||
default:
|
||||
return "TAKIP", "Sipariş, stok ve fiyat düzenli izlenmeli."
|
||||
return "TAKIP", "Siparis, stok ve fiyat duzenli izlenmeli."
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceOrderGroupRecommendation(row models.ProductPerformanceOrderGroupRow) (string, string) {
|
||||
switch {
|
||||
case row.ExpectedProfitCostUSD < 0:
|
||||
return "FIYAT_BASKISI", "Çıplak maliyete göre zarar yazan açık sipariş var. Fiyat/maliyet acil kontrol edilmeli."
|
||||
return "FIYAT_BASKISI", "Ciplak maliyete gore zarar yazan acik siparis var. Fiyat/maliyet acil kontrol edilmeli."
|
||||
case row.ExpectedProfitBaseUSD < 0:
|
||||
return "FIYAT_BASKISI", "Taban maliyete göre brüt zarar var. Satış fiyatı veya iskonto kontrol edilmeli."
|
||||
return "FIYAT_BASKISI", "Taban maliyete gore brut zarar var. Satis fiyati veya iskonto kontrol edilmeli."
|
||||
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
|
||||
return "STOKSUZ_TALEP", "Karlı açık talep var ama stok yetersiz. Üretim/satın alma önceliği ver."
|
||||
return "STOKSUZ_TALEP", "Karli acik talep var ama stok yetersiz. Uretim/satin alma onceligi ver."
|
||||
case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15:
|
||||
return "YILDIZ_URUN", "Piyasa/müşteri açık siparişleri karlı. Teslimat ve stok korunmalı."
|
||||
return "YILDIZ_URUN", "Piyasa/musteri acik siparisleri karli. Teslimat ve stok korunmali."
|
||||
case row.OverdueQty > 0:
|
||||
return "TAKIP", "Geciken açık sipariş var. Operasyon takibi gerekli."
|
||||
return "TAKIP", "Geciken acik siparis var. Operasyon takibi gerekli."
|
||||
default:
|
||||
return "TAKIP", "Sipariş karlılığı ve stok yeterliliği izlenmeli."
|
||||
return "TAKIP", "Siparis karliligi ve stok yeterliligi izlenmeli."
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceOrderProductCustomerRecommendation(row models.ProductPerformanceOrderProductCustomerRow) (string, string) {
|
||||
switch {
|
||||
case row.ExpectedProfitCostUSD < 0:
|
||||
return "FIYAT_BASKISI", "Bu müşteri/ürün açık siparişi çıplak maliyete göre zarar yazıyor."
|
||||
return "FIYAT_BASKISI", "Bu musteri/urun acik siparisi ciplak maliyete gore zarar yaziyor."
|
||||
case row.ExpectedProfitBaseUSD < 0:
|
||||
return "FIYAT_BASKISI", "Bu müşteri/ürün açık siparişi taban maliyete göre brüt zarar yazıyor."
|
||||
return "FIYAT_BASKISI", "Bu musteri/urun acik siparisi taban maliyete gore brut zarar yaziyor."
|
||||
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
|
||||
return "STOKSUZ_TALEP", "Müşteride karlı talep var ama stok yetersiz."
|
||||
return "STOKSUZ_TALEP", "Musteride karli talep var ama stok yetersiz."
|
||||
case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15:
|
||||
return "YILDIZ_URUN", "Müşteri bazında karlı açık talep var."
|
||||
return "YILDIZ_URUN", "Musteri bazinda karli acik talep var."
|
||||
case row.OverdueQty > 0:
|
||||
return "TAKIP", "Bu müşteri/ürün kırılımında geciken açık sipariş var."
|
||||
return "TAKIP", "Bu musteri/urun kiriliminda geciken acik siparis var."
|
||||
default:
|
||||
return "TAKIP", "Müşteri talebi, fiyat ve stok birlikte izlenmeli."
|
||||
return "TAKIP", "Musteri talebi, fiyat ve stok birlikte izlenmeli."
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceOrderMarketDetailRecommendation(row models.ProductPerformanceOrderMarketDetailRow) (string, string) {
|
||||
switch {
|
||||
case row.ExpectedProfitCostUSD < 0:
|
||||
return "FIYAT_BASKISI", "Sipariş satırı çıplak maliyete göre zarar yazıyor."
|
||||
return "FIYAT_BASKISI", "Siparis satiri ciplak maliyete gore zarar yaziyor."
|
||||
case row.ExpectedProfitBaseUSD < 0:
|
||||
return "FIYAT_BASKISI", "Sipariş satırı taban maliyete göre brüt zarar yazıyor."
|
||||
return "FIYAT_BASKISI", "Siparis satiri taban maliyete gore brut zarar yaziyor."
|
||||
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
|
||||
return "STOKSUZ_TALEP", "Karlı sipariş var ama mevcut stok siparişi karşılamıyor."
|
||||
return "STOKSUZ_TALEP", "Karli siparis var ama mevcut stok siparisi karsilamiyor."
|
||||
case row.IsOverdue:
|
||||
return "TAKIP", "Termin geçmiş; teslimat aksiyonu gerekli."
|
||||
return "TAKIP", "Termin gecmis; teslimat aksiyonu gerekli."
|
||||
case row.ExpectedMarginCost >= 0.25:
|
||||
return "YILDIZ_URUN", "Sipariş karlı; teslimat ve stok korunmalı."
|
||||
return "YILDIZ_URUN", "Siparis karli; teslimat ve stok korunmali."
|
||||
default:
|
||||
return "TAKIP", "Sipariş fiyatı, stok ve termin izlenmeli."
|
||||
return "TAKIP", "Siparis fiyati, stok ve termin izlenmeli."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,16 +76,19 @@ func productPerformanceSalesSQL() string {
|
||||
), SPACE(0)))
|
||||
END,
|
||||
Qty1 = ISNULL(I.Qty1, 0),
|
||||
TLAmount = ISNULL(I.Doc_Amount, 0) * ISNULL(I.Loc_ExchangeRate, 0),
|
||||
USDAmount = (ISNULL(I.Doc_Amount, 0) * ISNULL(I.Loc_ExchangeRate, 0)) / ISNULL((
|
||||
SELECT TOP 1 Rate
|
||||
FROM AllExchangeRates WITH(NOLOCK)
|
||||
WHERE CurrencyCode = 'USD'
|
||||
AND RelationCurrencyCode = 'TRY'
|
||||
AND ExchangeTypeCode = 6
|
||||
AND Rate > 1
|
||||
ORDER BY ABS(DATEDIFF(DAY, AllExchangeRates.Date, I.InvoiceDate)) ASC
|
||||
), 1),
|
||||
TLAmount = CASE
|
||||
WHEN Amount.DocCurrencyCode = 'TRY' THEN Amount.DocNetAmount
|
||||
WHEN CurRate.Rate > 0 THEN Amount.DocNetAmount * CurRate.Rate
|
||||
WHEN ISNULL(I.Loc_ExchangeRate, 0) > 0 THEN Amount.DocNetAmount * I.Loc_ExchangeRate
|
||||
ELSE 0
|
||||
END,
|
||||
USDAmount = CASE
|
||||
WHEN Amount.DocCurrencyCode = 'USD' THEN Amount.DocNetAmount
|
||||
WHEN Amount.DocCurrencyCode = 'TRY' AND UsdRate.Rate > 0 THEN Amount.DocNetAmount / UsdRate.Rate
|
||||
WHEN CurRate.Rate > 0 AND UsdRate.Rate > 0 THEN (Amount.DocNetAmount * CurRate.Rate) / UsdRate.Rate
|
||||
WHEN ISNULL(I.Loc_ExchangeRate, 0) > 0 AND UsdRate.Rate > 0 THEN (Amount.DocNetAmount * I.Loc_ExchangeRate) / UsdRate.Rate
|
||||
ELSE 0
|
||||
END,
|
||||
RN = ROW_NUMBER() OVER (
|
||||
PARTITION BY I.InvoiceLineID
|
||||
ORDER BY I.InvoiceDate DESC, I.InvoiceNumber
|
||||
@@ -96,6 +99,39 @@ func productPerformanceSalesSQL() string {
|
||||
AND CAF.CurrAccCode = I.CurrAccCode
|
||||
LEFT OUTER JOIN ProductAttributesFilter PAF WITH(NOLOCK)
|
||||
ON PAF.ItemCode = I.ItemCode
|
||||
LEFT OUTER JOIN dbo.trInvoiceLineCurrency ILC WITH(NOLOCK)
|
||||
ON ILC.InvoiceLineID = I.InvoiceLineID
|
||||
AND UPPER(LTRIM(RTRIM(ILC.CurrencyCode))) = UPPER(LTRIM(RTRIM(ISNULL(NULLIF(LTRIM(RTRIM(I.Doc_CurrencyCode)), ''), 'TRY'))))
|
||||
OUTER APPLY (
|
||||
SELECT
|
||||
DocCurrencyCode = UPPER(LTRIM(RTRIM(ISNULL(NULLIF(LTRIM(RTRIM(I.Doc_CurrencyCode)), ''), ISNULL(NULLIF(LTRIM(RTRIM(ILC.CurrencyCode)), ''), 'TRY'))))),
|
||||
DocNetAmount = COALESCE(
|
||||
NULLIF(ISNULL(ILC.NetAmount, 0), 0),
|
||||
NULLIF(ISNULL(I.Doc_Amount, 0), 0),
|
||||
NULLIF(ISNULL(I.Doc_Price, 0) * ISNULL(I.Qty1, 0), 0),
|
||||
0
|
||||
)
|
||||
) Amount
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 Rate
|
||||
FROM AllExchangeRates WITH(NOLOCK)
|
||||
WHERE CurrencyCode = 'USD'
|
||||
AND RelationCurrencyCode = 'TRY'
|
||||
AND ExchangeTypeCode = 6
|
||||
AND Rate > 0
|
||||
AND Date <= CAST(I.InvoiceDate AS date)
|
||||
ORDER BY Date DESC
|
||||
) UsdRate
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 Rate
|
||||
FROM AllExchangeRates WITH(NOLOCK)
|
||||
WHERE CurrencyCode = Amount.DocCurrencyCode
|
||||
AND RelationCurrencyCode = 'TRY'
|
||||
AND ExchangeTypeCode = 6
|
||||
AND Rate > 0
|
||||
AND Date <= CAST(I.InvoiceDate AS date)
|
||||
ORDER BY Date DESC
|
||||
) CurRate
|
||||
WHERE I.InvoiceDate >= @p1
|
||||
AND I.InvoiceDate < DATEADD(DAY, 1, @p2)
|
||||
AND I.ItemTypeCode = 1
|
||||
@@ -449,13 +485,20 @@ SalesAgg AS (
|
||||
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 '359 days') AS sales_usd_365d,
|
||||
SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01') AS sales_usd_total,
|
||||
COUNT(DISTINCT NULLIF(customer_code, '-')) 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' AND COALESCE(sales_usd, 0) > 0) AS customer_count_90d,
|
||||
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days' AND COALESCE(sales_usd, 0) > 0) AS customer_count_180d,
|
||||
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days' AND COALESCE(sales_usd, 0) > 0) AS customer_count_365d,
|
||||
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(sales_usd, 0) > 0) AS customer_count_total,
|
||||
MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS first_sale_date_90d,
|
||||
MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS first_sale_date_180d,
|
||||
MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days') AS first_sale_date_365d,
|
||||
MIN(sales_date) FILTER (WHERE sales_date >= DATE '2022-01-01') AS first_sale_date_total,
|
||||
MAX(sales_date) AS last_sale_date,
|
||||
MAX(last_ref_number) AS last_ref_number
|
||||
FROM mk_product_performance_sales_daily
|
||||
WHERE sales_date >= DATE '2022-01-01'
|
||||
AND sales_date <= $1::date
|
||||
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
GROUP BY product_code, color_code, yaka_kodu, market_key
|
||||
),
|
||||
Scope AS (
|
||||
@@ -464,7 +507,9 @@ Scope AS (
|
||||
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_180d, sales_qty_365d, sales_qty_730d, sales_qty_total,
|
||||
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total,
|
||||
customer_count_90d, last_sale_date, last_ref_number
|
||||
customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total,
|
||||
first_sale_date_90d, first_sale_date_180d, first_sale_date_365d, first_sale_date_total,
|
||||
last_sale_date, last_ref_number
|
||||
FROM SalesAgg
|
||||
UNION ALL
|
||||
SELECT
|
||||
@@ -492,12 +537,19 @@ Scope AS (
|
||||
0 AS sales_usd_365d,
|
||||
0 AS sales_usd_total,
|
||||
0 AS customer_count_90d,
|
||||
0 AS customer_count_180d,
|
||||
0 AS customer_count_365d,
|
||||
0 AS customer_count_total,
|
||||
NULL::date AS first_sale_date_90d,
|
||||
NULL::date AS first_sale_date_180d,
|
||||
NULL::date AS first_sale_date_365d,
|
||||
NULL::date AS first_sale_date_total,
|
||||
NULL::date AS last_sale_date,
|
||||
'' AS last_ref_number
|
||||
FROM LatestStock ls
|
||||
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=ls.product_code
|
||||
WHERE COALESCE(ls.stock_qty, 0) <> 0
|
||||
AND upper(btrim(COALESCE(pd.urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
AND upper(translate(btrim(COALESCE(pd.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM SalesAgg sa
|
||||
@@ -513,11 +565,11 @@ PriceDim AS (
|
||||
COALESCE(urun_ana_grubu, '') AS urun_ana_grubu,
|
||||
COALESCE(urun_alt_grubu, '') AS urun_alt_grubu,
|
||||
CASE
|
||||
WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd < base_price_usd THEN base_price_usd
|
||||
WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd > base_price_usd THEN base_price_usd
|
||||
ELSE cost_price_usd
|
||||
END AS cost_price_usd,
|
||||
CASE
|
||||
WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd < base_price_usd THEN cost_price_usd
|
||||
WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd > base_price_usd THEN cost_price_usd
|
||||
ELSE base_price_usd
|
||||
END AS base_price_usd,
|
||||
base_price_try
|
||||
@@ -534,10 +586,10 @@ Base AS (
|
||||
COALESCE(s.sales_qty_180d, 0) / 180.0 AS avg_daily_sales_180d,
|
||||
COALESCE(s.sales_qty_365d, 0) / 360.0 AS avg_daily_sales_365d,
|
||||
COALESCE(s.sales_qty_total, 0) / GREATEST(1, ($1::date - DATE '2022-01-01') + 1) AS avg_daily_sales_total,
|
||||
(COALESCE(s90.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_90d,
|
||||
(COALESCE(s180.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_180d,
|
||||
(COALESCE(s365.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_365d,
|
||||
(COALESCE(stotal.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_total,
|
||||
(COALESCE(s90.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_90d,
|
||||
(COALESCE(s180.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_180d,
|
||||
(COALESCE(s365.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_365d,
|
||||
(COALESCE(stotal.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_total,
|
||||
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,
|
||||
@@ -554,27 +606,68 @@ Base AS (
|
||||
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 Stock90Start s90 ON s90.product_code=s.product_code AND s90.color_code=s.color_code AND s90.yaka_kodu=s.yaka_kodu
|
||||
LEFT JOIN Stock180Start s180 ON s180.product_code=s.product_code AND s180.color_code=s.color_code AND s180.yaka_kodu=s.yaka_kodu
|
||||
LEFT JOIN Stock365Start s365 ON s365.product_code=s.product_code AND s365.color_code=s.color_code AND s365.yaka_kodu=s.yaka_kodu
|
||||
LEFT JOIN StockTotalStart stotal ON stotal.product_code=s.product_code AND stotal.color_code=s.color_code AND stotal.yaka_kodu=s.yaka_kodu
|
||||
LEFT JOIN PriceDim pd ON pd.product_code=s.product_code
|
||||
WHERE upper(btrim(COALESCE(NULLIF(s.urun_ilk_grubu,''), pd.urun_ilk_grubu, ''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT x.stock_qty
|
||||
FROM mk_product_performance_stock_daily x
|
||||
WHERE x.product_code=s.product_code
|
||||
AND x.color_code=s.color_code
|
||||
AND x.yaka_kodu=s.yaka_kodu
|
||||
AND x.stock_date <= COALESCE(s.first_sale_date_90d, $1::date - INTERVAL '89 days')
|
||||
ORDER BY x.stock_date DESC
|
||||
LIMIT 1
|
||||
) s90 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT x.stock_qty
|
||||
FROM mk_product_performance_stock_daily x
|
||||
WHERE x.product_code=s.product_code
|
||||
AND x.color_code=s.color_code
|
||||
AND x.yaka_kodu=s.yaka_kodu
|
||||
AND x.stock_date <= COALESCE(s.first_sale_date_180d, $1::date - INTERVAL '179 days')
|
||||
ORDER BY x.stock_date DESC
|
||||
LIMIT 1
|
||||
) s180 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT x.stock_qty
|
||||
FROM mk_product_performance_stock_daily x
|
||||
WHERE x.product_code=s.product_code
|
||||
AND x.color_code=s.color_code
|
||||
AND x.yaka_kodu=s.yaka_kodu
|
||||
AND x.stock_date <= COALESCE(s.first_sale_date_365d, $1::date - INTERVAL '359 days')
|
||||
ORDER BY x.stock_date DESC
|
||||
LIMIT 1
|
||||
) s365 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT x.stock_qty
|
||||
FROM mk_product_performance_stock_daily x
|
||||
WHERE x.product_code=s.product_code
|
||||
AND x.color_code=s.color_code
|
||||
AND x.yaka_kodu=s.yaka_kodu
|
||||
AND x.stock_date <= COALESCE(s.first_sale_date_total, DATE '2022-01-01')
|
||||
ORDER BY x.stock_date DESC
|
||||
LIMIT 1
|
||||
) stotal ON TRUE
|
||||
WHERE upper(translate(btrim(COALESCE(NULLIF(s.urun_ilk_grubu,''), pd.urun_ilk_grubu, '')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
||||
),
|
||||
Spread AS (
|
||||
SELECT
|
||||
product_code,
|
||||
color_code,
|
||||
yaka_kodu,
|
||||
COUNT(DISTINCT market_key) AS market_count_90d
|
||||
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_90d, 0) > 0) AS market_count_90d,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_180d, 0) > 0) AS market_count_180d,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_365d, 0) > 0) AS market_count_365d,
|
||||
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_total, 0) > 0) AS market_count_total
|
||||
FROM SalesAgg
|
||||
WHERE COALESCE(sales_qty_90d, 0) > 0
|
||||
GROUP BY product_code, color_code, yaka_kodu
|
||||
),
|
||||
Market AS (
|
||||
SELECT
|
||||
market_key,
|
||||
AVG(NULLIF(sales_qty_90d, 0)) AS market_avg_sales_qty_90d,
|
||||
AVG(NULLIF(sales_qty_180d, 0)) AS market_avg_sales_qty_180d,
|
||||
AVG(NULLIF(sales_qty_365d, 0)) AS market_avg_sales_qty_365d,
|
||||
AVG(NULLIF(sales_qty_total, 0)) AS market_avg_sales_qty_total,
|
||||
AVG(NULLIF(avg_price_usd_90d, 0)) AS market_avg_price_usd_90d,
|
||||
AVG(NULLIF(gross_margin_90d, 0)) AS market_avg_margin_90d
|
||||
FROM Base
|
||||
@@ -584,15 +677,21 @@ Scored AS (
|
||||
SELECT
|
||||
b.*,
|
||||
COALESCE(sp.market_count_90d, 0) AS market_count_90d,
|
||||
COALESCE(sp.market_count_180d, 0) AS market_count_180d,
|
||||
COALESCE(sp.market_count_365d, 0) AS market_count_365d,
|
||||
COALESCE(sp.market_count_total, 0) AS market_count_total,
|
||||
CASE WHEN b.avg_daily_sales_90d = 0 THEN 0 ELSE b.avg_stock_90d / NULLIF(b.avg_daily_sales_90d, 0) END AS stock_days_90d,
|
||||
CASE WHEN b.avg_daily_sales_180d = 0 THEN 0 ELSE b.avg_stock_180d / NULLIF(b.avg_daily_sales_180d, 0) END AS stock_days_180d,
|
||||
CASE WHEN b.avg_daily_sales_365d = 0 THEN 0 ELSE b.avg_stock_365d / NULLIF(b.avg_daily_sales_365d, 0) END AS stock_days_365d,
|
||||
CASE WHEN b.avg_daily_sales_total = 0 THEN 0 ELSE b.avg_stock_total / NULLIF(b.avg_daily_sales_total, 0) END AS stock_days_total,
|
||||
CASE WHEN b.avg_stock_90d = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(b.avg_stock_90d, 0) END AS stock_turnover_90d,
|
||||
CASE WHEN b.avg_stock_180d = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(b.avg_stock_180d, 0) END AS stock_turnover_180d,
|
||||
CASE WHEN b.avg_stock_90d = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(b.avg_stock_90d, 0) * 4.0 END AS stock_turnover_90d,
|
||||
CASE WHEN b.avg_stock_180d = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(b.avg_stock_180d, 0) * 2.0 END AS stock_turnover_180d,
|
||||
CASE WHEN b.avg_stock_365d = 0 THEN 0 ELSE b.sales_qty_365d / NULLIF(b.avg_stock_365d, 0) END AS stock_turnover_365d,
|
||||
CASE WHEN b.avg_stock_total = 0 THEN 0 ELSE b.sales_qty_total / NULLIF(b.avg_stock_total, 0) END AS stock_turnover_total,
|
||||
CASE WHEN b.avg_stock_total = 0 THEN 0 ELSE b.sales_qty_total / NULLIF(b.avg_stock_total, 0) * 360.0 / GREATEST(1, ($1::date - DATE '2022-01-01') + 1) END AS stock_turnover_total,
|
||||
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_180d, 0) = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(m.market_avg_sales_qty_180d, 0) END AS sales_index_180d,
|
||||
CASE WHEN COALESCE(m.market_avg_sales_qty_365d, 0) = 0 THEN 0 ELSE b.sales_qty_365d / NULLIF(m.market_avg_sales_qty_365d, 0) END AS sales_index_365d,
|
||||
CASE WHEN COALESCE(m.market_avg_sales_qty_total, 0) = 0 THEN 0 ELSE b.sales_qty_total / NULLIF(m.market_avg_sales_qty_total, 0) END AS sales_index_total,
|
||||
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
|
||||
@@ -604,11 +703,14 @@ INSERT INTO mk_product_performance_kpi_daily (
|
||||
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_180d, sales_qty_365d, sales_qty_730d, sales_qty_total,
|
||||
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total, avg_daily_sales_90d, avg_daily_sales_180d, avg_daily_sales_365d, avg_daily_sales_total,
|
||||
avg_stock_90d, avg_stock_180d, avg_stock_365d, avg_stock_total,
|
||||
stock_days_90d, stock_days_180d, stock_days_365d, stock_days_total, stock_turnover_90d, stock_turnover_180d, stock_turnover_365d, stock_turnover_total,
|
||||
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,
|
||||
unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d,
|
||||
market_count_90d, market_count_180d, market_count_365d, market_count_total,
|
||||
customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total,
|
||||
sales_index_90d, sales_index_180d, sales_index_365d, sales_index_total, price_index_90d, margin_index_90d, performance_score,
|
||||
performance_bucket, recommendation, last_sale_date, last_ref_number, updated_at
|
||||
)
|
||||
SELECT
|
||||
@@ -617,20 +719,25 @@ SELECT
|
||||
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_180d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0), COALESCE(sales_qty_total, 0),
|
||||
COALESCE(sales_usd_30d, 0), COALESCE(sales_usd_90d, 0), COALESCE(sales_usd_180d, 0), COALESCE(sales_usd_365d, 0), COALESCE(sales_usd_total, 0), COALESCE(avg_daily_sales_90d, 0), COALESCE(avg_daily_sales_180d, 0), COALESCE(avg_daily_sales_365d, 0), COALESCE(avg_daily_sales_total, 0),
|
||||
COALESCE(avg_stock_90d, 0), COALESCE(avg_stock_180d, 0), COALESCE(avg_stock_365d, 0), COALESCE(avg_stock_total, 0),
|
||||
COALESCE(stock_days_90d, 0), COALESCE(stock_days_180d, 0), COALESCE(stock_days_365d, 0), COALESCE(stock_days_total, 0), COALESCE(stock_turnover_90d, 0), COALESCE(stock_turnover_180d, 0), COALESCE(stock_turnover_365d, 0), COALESCE(stock_turnover_total, 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(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)
|
||||
+ 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,
|
||||
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(market_count_180d, 0), COALESCE(market_count_365d, 0), COALESCE(market_count_total, 0),
|
||||
COALESCE(customer_count_90d, 0), COALESCE(customer_count_180d, 0), COALESCE(customer_count_365d, 0), COALESCE(customer_count_total, 0),
|
||||
COALESCE(sales_index_90d, 0), COALESCE(sales_index_180d, 0), COALESCE(sales_index_365d, 0), COALESCE(sales_index_total, 0), COALESCE(price_index_90d, 0), COALESCE(margin_index_90d, 0),
|
||||
CASE WHEN COALESCE(sales_usd_90d, 0) <= 0 THEN 1 ELSE
|
||||
ROUND(
|
||||
LEAST(30, GREATEST(COALESCE(gross_margin_90d, 0), 0) / 0.45 * 30)
|
||||
+ LEAST(20, COALESCE(stock_turnover_90d, 0) / 4.0 * 20)
|
||||
+ LEAST(20, COALESCE(sales_usd_90d, 0) / 25000.0 * 20)
|
||||
+ LEAST(5, COALESCE(market_count_90d, 0) / 3.0 * 5)
|
||||
+ LEAST(25, COALESCE(customer_count_90d, 0) / 8.0 * 25)
|
||||
, 4)
|
||||
END AS performance_score,
|
||||
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.45 THEN 'YILDIZ_URUN'
|
||||
WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'STOKSUZ_TALEP'
|
||||
WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'STOK_RISKI'
|
||||
WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'FIYAT_BASKISI'
|
||||
@@ -638,12 +745,12 @@ SELECT
|
||||
ELSE 'TAKIP'
|
||||
END AS performance_bucket,
|
||||
CASE
|
||||
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'Piyasa üstü satış ve iyi marj: stok ve fiyat gücü takip edilmeli.'
|
||||
WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: üretim/satın alma planına alınmalı.'
|
||||
WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'Stok yüksek, satış piyasa altı: kampanya veya fiyat kontrolü gerekli.'
|
||||
WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa üstünde ve satış zayıf: fiyat revizyonu değerlendirilmeli.'
|
||||
WHEN COALESCE(price_index_90d,0) < 0.90 AND COALESCE(sales_index_90d,0) > 1.00 THEN 'Satış güçlü, fiyat piyasa altı: taban fiyat artışı değerlendirilebilir.'
|
||||
ELSE 'Düzenli takip.'
|
||||
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.45 THEN 'Piyasa ustu satis ve iyi marj: stok ve fiyat gucu takip edilmeli.'
|
||||
WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: uretim/satin alma planina alinmali.'
|
||||
WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'Stok yuksek, satis piyasa alti: kampanya veya fiyat kontrolu gerekli.'
|
||||
WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa ustunde ve satis zayif: fiyat revizyonu degerlendirilmeli.'
|
||||
WHEN COALESCE(price_index_90d,0) < 0.90 AND COALESCE(sales_index_90d,0) > 1.00 THEN 'Satis guclu, fiyat piyasa alti: taban fiyat artisi degerlendirilebilir.'
|
||||
ELSE 'Duzenli takip.'
|
||||
END AS recommendation,
|
||||
last_sale_date,
|
||||
COALESCE(last_ref_number, ''),
|
||||
|
||||
@@ -793,6 +793,82 @@ ORDER BY SonIsEmri.dteIslemTarihi DESC, LTRIM(RTRIM(R.sMModelKodu)) ASC
|
||||
return uretimDB.QueryContext(ctx, sqlText, fromDate, search)
|
||||
}
|
||||
|
||||
type ArgeBaggiDepotEntryTotals struct {
|
||||
Ceket float64
|
||||
Pantolon float64
|
||||
Yelek float64
|
||||
}
|
||||
|
||||
// GetArgeBaggiDepotEntryTotals returns operation 985/986/987 quantities keyed
|
||||
// by production work order. The requested work orders are queried in one batch
|
||||
// to avoid an ArgeBAGGI round trip for every list row.
|
||||
func GetArgeBaggiDepotEntryTotals(ctx context.Context, argeDB *sql.DB, workOrders []string) (map[string]ArgeBaggiDepotEntryTotals, error) {
|
||||
out := make(map[string]ArgeBaggiDepotEntryTotals)
|
||||
if argeDB == nil || len(workOrders) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(workOrders))
|
||||
values := make([]string, 0, len(workOrders))
|
||||
for _, raw := range workOrders {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" || value == "0" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
values = append(values, value)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, 0, len(values))
|
||||
args := make([]any, 0, len(values))
|
||||
for i, value := range values {
|
||||
placeholders = append(placeholders, fmt.Sprintf("@p%d", i+1))
|
||||
args = append(args, value)
|
||||
}
|
||||
|
||||
sqlText := fmt.Sprintf(`
|
||||
SELECT
|
||||
LTRIM(RTRIM(CONVERT(VARCHAR(64), MN.model_adi))) AS IsEmriNo,
|
||||
SUM(CASE WHEN O.oper_num = 985 THEN CONVERT(float, B.adet) ELSE 0 END) AS CeketDepoGiris,
|
||||
SUM(CASE WHEN O.oper_num = 986 THEN CONVERT(float, B.adet) ELSE 0 END) AS PantolonDepoGiris,
|
||||
SUM(CASE WHEN O.oper_num = 987 THEN CONVERT(float, B.adet) ELSE 0 END) AS YelekDepoGiris
|
||||
FROM dbo.is_acma_bar B
|
||||
INNER JOIN dbo.is_acma I ON I.is_num = B.is_num
|
||||
INNER JOIN dbo.model_def MD ON I.imdef_num = MD.mdef_num
|
||||
INNER JOIN dbo.oper_urun_no O ON B.oper_num = O.oper_num
|
||||
INNER JOIN dbo.model_num MN ON I.model_num = MN.model_num
|
||||
INNER JOIN dbo.bant_no BN ON I.bant_num = BN.bant_no
|
||||
INNER JOIN dbo.model_oper MO ON O.oper_num = MO.oper_num AND MD.mdef_num = MO.model_num
|
||||
INNER JOIN dbo.model_oper_tkp_def TD ON MO.tkp_cik = TD.tkp_cik
|
||||
WHERE B.tarih >= '2025-01-01'
|
||||
AND TD.tkp_cik IN (1,2,3,4,5)
|
||||
AND O.oper_num IN (985,986,987)
|
||||
AND LTRIM(RTRIM(CONVERT(VARCHAR(64), MN.model_adi))) IN (%s)
|
||||
GROUP BY LTRIM(RTRIM(CONVERT(VARCHAR(64), MN.model_adi)))
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
rows, err := argeDB.QueryContext(ctx, sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var workOrder string
|
||||
var totals ArgeBaggiDepotEntryTotals
|
||||
if err := rows.Scan(&workOrder, &totals.Ceket, &totals.Pantolon, &totals.Yelek); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[strings.TrimSpace(workOrder)] = totals
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func GetProductionHasCostProducts(
|
||||
ctx context.Context,
|
||||
uretimDB *sql.DB,
|
||||
@@ -816,12 +892,22 @@ WITH SonOnMaliyet AS (
|
||||
FROM dbo.spUrtOnMLMas M
|
||||
WHERE LTRIM(RTRIM(M.UrunKodu)) <> ''
|
||||
),
|
||||
SonSiparis AS (
|
||||
SonSiparisSirali AS (
|
||||
SELECT
|
||||
LTRIM(RTRIM(sMModelKodu)) AS UrunKodu,
|
||||
MAX(dteIslemTarihi) AS SonSiparisTarihi
|
||||
FROM dbo.spUrtSiparisDet
|
||||
GROUP BY LTRIM(RTRIM(sMModelKodu))
|
||||
LTRIM(RTRIM(SD.sMModelKodu)) AS UrunKodu,
|
||||
SD.dteIslemTarihi AS SonSiparisTarihi,
|
||||
SM.nUrtSiparisNo,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY LTRIM(RTRIM(SD.sMModelKodu))
|
||||
ORDER BY SD.dteIslemTarihi DESC, SD.nUrtSiparisID DESC
|
||||
) AS rn
|
||||
FROM dbo.spUrtSiparisDet SD
|
||||
INNER JOIN dbo.spUrtSiparis SM ON SM.nUrtSiparisID = SD.nUrtSiparisID
|
||||
),
|
||||
SonSiparis AS (
|
||||
SELECT UrunKodu, SonSiparisTarihi, nUrtSiparisNo
|
||||
FROM SonSiparisSirali
|
||||
WHERE rn = 1
|
||||
)
|
||||
SELECT
|
||||
CASE
|
||||
@@ -831,6 +917,7 @@ SELECT
|
||||
THEN N'FASON ICIN URETILEN'
|
||||
ELSE N'Tanimsiz'
|
||||
END AS UretimSekli,
|
||||
RTRIM(CONVERT(VARCHAR(32), ISNULL(SS.nUrtSiparisNo, 0))) AS nUrtSiparisNo,
|
||||
RTRIM(CONVERT(VARCHAR(32), ISNULL(OM.nOnMLNo, 0))) AS nOnMLNo,
|
||||
LTRIM(RTRIM(ISNULL(OM.UrunKodu, ''))) AS UrunKodu,
|
||||
ISNULL(OM.UrunAdi, '') AS UrunAdi,
|
||||
|
||||
+136
-25
@@ -17,14 +17,16 @@ import (
|
||||
)
|
||||
|
||||
type ProductImageItem struct {
|
||||
ID int64 `json:"id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Storage string `json:"storage_path"`
|
||||
ContentURL string `json:"content_url"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
FullURL string `json:"full_url,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Storage string `json:"storage_path"`
|
||||
ContentURL string `json:"content_url"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
FullURL string `json:"full_url,omitempty"`
|
||||
StoredInDB bool `json:"stored_in_db,omitempty"`
|
||||
HasDBContent bool `json:"-"`
|
||||
}
|
||||
|
||||
type ProductImageBatchItem struct {
|
||||
@@ -136,6 +138,74 @@ func extractImageUUID(storagePath, fileName string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func enrichProductImageItem(it *ProductImageItem) {
|
||||
if it == nil {
|
||||
return
|
||||
}
|
||||
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
|
||||
it.UUID = u
|
||||
it.ThumbURL = productImagePublicURLIfExists("uploads/image/t300/" + u + ".jpg")
|
||||
it.FullURL = productImagePublicURLIfExists("uploads/image/" + u + ".jpg")
|
||||
}
|
||||
if it.FullURL == "" {
|
||||
it.FullURL = productImagePublicURLIfExists(it.Storage)
|
||||
}
|
||||
if it.FullURL == "" {
|
||||
it.FullURL = productImagePublicURLIfExists(it.FileName)
|
||||
}
|
||||
if it.ID > 0 && (it.HasDBContent || productImageStoredFileExists(it.Storage, it.FileName)) {
|
||||
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func productImageItemHasURL(it *ProductImageItem) bool {
|
||||
return it != nil && (it.ThumbURL != "" || it.FullURL != "" || it.ContentURL != "")
|
||||
}
|
||||
|
||||
func productImageStoredFileExists(storagePath, fileName string) bool {
|
||||
if resolved, _ := resolveStoragePath(storagePath); resolved != "" {
|
||||
return true
|
||||
}
|
||||
if resolved, _ := resolveStoragePath(fileName); resolved != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func productImagePublicURLIfExists(storagePath string) string {
|
||||
raw := strings.TrimSpace(storagePath)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
resolved, _ := resolveStoragePath(raw)
|
||||
if resolved == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalized := strings.ReplaceAll(raw, "\\", "/")
|
||||
lower := strings.ToLower(normalized)
|
||||
if idx := strings.Index(lower, "/uploads/"); idx >= 0 {
|
||||
return normalized[idx:]
|
||||
}
|
||||
if strings.HasPrefix(lower, "uploads/") {
|
||||
return "/" + normalized
|
||||
}
|
||||
|
||||
root := strings.TrimSpace(os.Getenv("BLOB_ROOT"))
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
rel, err := filepath.Rel(root, resolved)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||
return ""
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if strings.HasPrefix(strings.ToLower(rel), "uploads/") {
|
||||
return "/" + rel
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// POST /api/product-images/batch
|
||||
func PostProductImagesBatchHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -208,6 +278,8 @@ ranked AS (
|
||||
COALESCE(b.file_name,'') AS file_name,
|
||||
COALESCE(b.file_size,0) AS file_size,
|
||||
COALESCE(b.storage_path,'') AS storage_path,
|
||||
COALESCE(b.stored_in_db,false) AS stored_in_db,
|
||||
(COALESCE(octet_length(b.bin),0) > 0) AS has_db_content,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY mi.key
|
||||
ORDER BY
|
||||
@@ -225,9 +297,9 @@ ranked AS (
|
||||
AND b.typ='img'
|
||||
AND b.src_id=mi.mmitem_id
|
||||
)
|
||||
SELECT key, id, file_name, file_size, storage_path
|
||||
SELECT key, id, file_name, file_size, storage_path, stored_in_db, has_db_content
|
||||
FROM ranked
|
||||
WHERE rn <= 1
|
||||
WHERE rn <= 5
|
||||
ORDER BY key, rn
|
||||
`, string(payload))
|
||||
if err != nil {
|
||||
@@ -241,14 +313,12 @@ ORDER BY key, rn
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var it ProductImageItem
|
||||
if err := rows.Scan(&key, &it.ID, &it.FileName, &it.FileSize, &it.Storage); err != nil {
|
||||
if err := rows.Scan(&key, &it.ID, &it.FileName, &it.FileSize, &it.Storage, &it.StoredInDB, &it.HasDBContent); err != nil {
|
||||
continue
|
||||
}
|
||||
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
|
||||
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
|
||||
it.UUID = u
|
||||
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
|
||||
it.FullURL = "/uploads/image/" + u + ".jpg"
|
||||
enrichProductImageItem(&it)
|
||||
if !productImageItemHasURL(&it) {
|
||||
continue
|
||||
}
|
||||
grouped[key] = append(grouped[key], it)
|
||||
}
|
||||
@@ -339,7 +409,9 @@ SELECT
|
||||
id,
|
||||
COALESCE(file_name,'') AS file_name,
|
||||
COALESCE(file_size,0) AS file_size,
|
||||
COALESCE(storage_path,'') AS storage_path
|
||||
COALESCE(storage_path,'') AS storage_path,
|
||||
COALESCE(stored_in_db,false) AS stored_in_db,
|
||||
(COALESCE(octet_length(bin),0) > 0) AS has_db_content
|
||||
FROM dfblob
|
||||
WHERE typ='img'
|
||||
AND src_table='mmitem'
|
||||
@@ -370,16 +442,13 @@ ORDER BY
|
||||
items := make([]ProductImageItem, 0, 16)
|
||||
for rows.Next() {
|
||||
var it ProductImageItem
|
||||
if err := rows.Scan(&it.ID, &it.FileName, &it.FileSize, &it.Storage); err != nil {
|
||||
if err := rows.Scan(&it.ID, &it.FileName, &it.FileSize, &it.Storage, &it.StoredInDB, &it.HasDBContent); err != nil {
|
||||
continue
|
||||
}
|
||||
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
|
||||
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
|
||||
it.UUID = u
|
||||
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
|
||||
it.FullURL = "/uploads/image/" + u + ".jpg"
|
||||
enrichProductImageItem(&it)
|
||||
if productImageItemHasURL(&it) {
|
||||
items = append(items, it)
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -521,7 +590,8 @@ WHERE id = $1
|
||||
return
|
||||
}
|
||||
|
||||
if storedInDB && len(binData) > 0 {
|
||||
_ = storedInDB
|
||||
if len(binData) > 0 {
|
||||
w.Header().Set("Content-Type", http.DetectContentType(binData))
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
_, _ = w.Write(binData)
|
||||
@@ -529,6 +599,9 @@ WHERE id = $1
|
||||
}
|
||||
|
||||
resolved, _ := resolveStoragePath(storagePath)
|
||||
if resolved == "" {
|
||||
resolved, _ = resolveStoragePath(fileName)
|
||||
}
|
||||
if resolved == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -562,14 +635,42 @@ func resolveStoragePath(storagePath string) (string, []string) {
|
||||
raw = filepath.ToSlash(filepath.Clean(raw))
|
||||
|
||||
relUploads := filepath.FromSlash(filepath.Join("uploads", raw))
|
||||
baseName := filepath.Base(raw)
|
||||
relImage := filepath.FromSlash(filepath.Join("uploads", "image", baseName))
|
||||
relImageThumb := filepath.FromSlash(filepath.Join("uploads", "image", "t300", baseName))
|
||||
candidates := []string{
|
||||
filepath.Clean(storagePath),
|
||||
filepath.FromSlash(filepath.Clean(strings.TrimPrefix(storagePath, "/"))),
|
||||
filepath.FromSlash(filepath.Clean(raw)),
|
||||
relUploads,
|
||||
relImage,
|
||||
relImageThumb,
|
||||
filepath.Join(".", relUploads),
|
||||
filepath.Join(".", relImage),
|
||||
filepath.Join(".", relImageThumb),
|
||||
filepath.Join("..", relUploads),
|
||||
filepath.Join("..", relImage),
|
||||
filepath.Join("..", relImageThumb),
|
||||
filepath.Join("..", "..", relUploads),
|
||||
filepath.Join("..", "..", relImage),
|
||||
filepath.Join("..", "..", relImageThumb),
|
||||
}
|
||||
|
||||
if u := extractImageUUID(raw, baseName); u != "" {
|
||||
uFull := filepath.FromSlash(filepath.Join("uploads", "image", u+".jpg"))
|
||||
uThumb := filepath.FromSlash(filepath.Join("uploads", "image", "t300", u+".jpg"))
|
||||
candidates = append(candidates,
|
||||
filepath.FromSlash(filepath.Join("image", u+".jpg")),
|
||||
filepath.FromSlash(filepath.Join("image", "t300", u+".jpg")),
|
||||
uFull,
|
||||
uThumb,
|
||||
filepath.Join(".", uFull),
|
||||
filepath.Join(".", uThumb),
|
||||
filepath.Join("..", uFull),
|
||||
filepath.Join("..", uThumb),
|
||||
filepath.Join("..", "..", uFull),
|
||||
filepath.Join("..", "..", uThumb),
|
||||
)
|
||||
}
|
||||
|
||||
if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" {
|
||||
@@ -577,7 +678,17 @@ func resolveStoragePath(storagePath string) (string, []string) {
|
||||
filepath.Join(root, raw),
|
||||
filepath.Join(root, relUploads),
|
||||
filepath.Join(root, "uploads", raw),
|
||||
filepath.Join(root, relImage),
|
||||
filepath.Join(root, relImageThumb),
|
||||
)
|
||||
if u := extractImageUUID(raw, baseName); u != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(root, "uploads", "image", u+".jpg"),
|
||||
filepath.Join(root, "uploads", "image", "t300", u+".jpg"),
|
||||
filepath.Join(root, "image", u+".jpg"),
|
||||
filepath.Join(root, "image", "t300", u+".jpg"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range candidates {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -16,7 +17,7 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
f := queries.ProductPerformanceFilters{
|
||||
@@ -90,7 +91,7 @@ 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")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
summary, err := queries.GetProductPerformanceSummary(ctx, pg)
|
||||
@@ -106,7 +107,7 @@ func GetProductPerformanceGeneralHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queries.ListProductPerformanceGeneral(ctx, pg, intQuery(r, "limit", 50000))
|
||||
@@ -122,7 +123,7 @@ func GetProductPerformanceOrdersHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queries.ListProductPerformanceOrderAnalysis(ctx, pg, intQuery(r, "limit", 50000))
|
||||
@@ -138,7 +139,7 @@ func GetProductPerformanceOrderGroupsHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queries.ListProductPerformanceOrderGroups(
|
||||
@@ -159,7 +160,7 @@ func GetProductPerformanceOrderProductCustomersHandler(pg *sql.DB) http.HandlerF
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queries.ListProductPerformanceOrderProductCustomers(ctx, pg, intQuery(r, "limit", 50000))
|
||||
@@ -175,7 +176,7 @@ func GetProductPerformanceOrderMarketDetailsHandler(pg *sql.DB) http.HandlerFunc
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queries.ListProductPerformanceOrderMarketDetails(ctx, pg, intQuery(r, "limit", 50000))
|
||||
@@ -191,7 +192,7 @@ func GetProductPerformanceMarketsHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queries.ListProductPerformanceMarkets(ctx, pg, intQuery(r, "limit", 50000))
|
||||
@@ -207,7 +208,7 @@ func GetProductPerformanceCountriesHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queries.ListProductPerformanceCountries(ctx, pg, intQuery(r, "limit", 50000))
|
||||
@@ -223,7 +224,7 @@ func GetProductPerformanceCustomersHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queries.ListProductPerformanceCustomers(
|
||||
@@ -244,7 +245,7 @@ func GetProductPerformanceSalesBreakdownHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 45*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queries.ListProductPerformanceSalesBreakdown(
|
||||
@@ -265,21 +266,41 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
||||
mainGroup := strings.TrimSpace(r.URL.Query().Get("urun_ana_grubu"))
|
||||
sortBy := strings.TrimSpace(r.URL.Query().Get("sort_by"))
|
||||
descending := boolQuery(r, "descending", true)
|
||||
groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels"))
|
||||
limit := intQuery(r, "limit", 50000)
|
||||
expanded := map[string]bool{}
|
||||
filters := map[string][]string{}
|
||||
expandThroughLevel := -1
|
||||
if rawExpandLevel := strings.TrimSpace(r.URL.Query().Get("expand_through_level")); rawExpandLevel != "" {
|
||||
if parsed, err := strconv.Atoi(rawExpandLevel); err == nil {
|
||||
expandThroughLevel = parsed
|
||||
}
|
||||
}
|
||||
const maxExpandedGroupKeys = 1000
|
||||
addExpandedKey := func(key string) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" && len(expanded) < maxExpandedGroupKeys {
|
||||
expanded[key] = true
|
||||
}
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
var body struct {
|
||||
Mode string `json:"mode"`
|
||||
GroupLevels []string `json:"group_levels"`
|
||||
ExpandedKeys []string `json:"expanded_keys"`
|
||||
Limit int `json:"limit"`
|
||||
MainGroup string `json:"urun_ana_grubu"`
|
||||
Mode string `json:"mode"`
|
||||
GroupLevels []string `json:"group_levels"`
|
||||
ExpandedKeys []string `json:"expanded_keys"`
|
||||
ExpandThroughLevel *int `json:"expand_through_level"`
|
||||
Limit int `json:"limit"`
|
||||
MainGroup string `json:"urun_ana_grubu"`
|
||||
Filters map[string][]string `json:"filters"`
|
||||
SortBy string `json:"sort_by"`
|
||||
Descending *bool `json:"descending"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -298,28 +319,39 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
|
||||
limit = body.Limit
|
||||
}
|
||||
for _, key := range body.ExpandedKeys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
expanded[key] = true
|
||||
}
|
||||
addExpandedKey(key)
|
||||
}
|
||||
if body.ExpandThroughLevel != nil {
|
||||
expandThroughLevel = *body.ExpandThroughLevel
|
||||
}
|
||||
if len(body.Filters) > 0 {
|
||||
filters = body.Filters
|
||||
}
|
||||
if strings.TrimSpace(body.SortBy) != "" {
|
||||
sortBy = strings.TrimSpace(body.SortBy)
|
||||
}
|
||||
if body.Descending != nil {
|
||||
descending = *body.Descending
|
||||
}
|
||||
} else {
|
||||
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
expanded[key] = true
|
||||
}
|
||||
addExpandedKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := queries.ListProductPerformanceGrouped(ctx, pg, queries.ProductPerformanceGroupedRequest{
|
||||
Mode: mode,
|
||||
GroupLevels: groupLevels,
|
||||
ExpandedKeys: expanded,
|
||||
Limit: limit,
|
||||
MainGroup: mainGroup,
|
||||
Mode: mode,
|
||||
GroupLevels: groupLevels,
|
||||
ExpandedKeys: expanded,
|
||||
ExpandThroughLevel: expandThroughLevel,
|
||||
Limit: limit,
|
||||
MainGroup: mainGroup,
|
||||
Filters: filters,
|
||||
SortBy: sortBy,
|
||||
Descending: descending,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[ProductPerformance][grouped] failed trace_id=%s mode=%s levels=%v expand_through_level=%d filters=%d: %v", traceID, mode, groupLevels, expandThroughLevel, len(filters), err)
|
||||
http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -327,6 +359,64 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func GetProductPerformanceGroupedFilterOptionsHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
||||
mainGroup := strings.TrimSpace(r.URL.Query().Get("urun_ana_grubu"))
|
||||
groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels"))
|
||||
fields := splitCSVQuery(r.URL.Query().Get("fields"))
|
||||
limit := intQuery(r, "limit", 50000)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
var body struct {
|
||||
Mode string `json:"mode"`
|
||||
GroupLevels []string `json:"group_levels"`
|
||||
MainGroup string `json:"urun_ana_grubu"`
|
||||
Fields []string `json:"fields"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "gecersiz filtre secenek istegi: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Mode) != "" {
|
||||
mode = strings.TrimSpace(body.Mode)
|
||||
}
|
||||
if strings.TrimSpace(body.MainGroup) != "" {
|
||||
mainGroup = strings.TrimSpace(body.MainGroup)
|
||||
}
|
||||
if len(body.GroupLevels) > 0 {
|
||||
groupLevels = body.GroupLevels
|
||||
}
|
||||
if len(body.Fields) > 0 {
|
||||
fields = body.Fields
|
||||
}
|
||||
if body.Limit > 0 {
|
||||
limit = body.Limit
|
||||
}
|
||||
}
|
||||
|
||||
options, err := queries.ListProductPerformanceGroupedFilterOptions(ctx, pg, queries.ProductPerformanceGroupedFilterOptionsRequest{
|
||||
Mode: mode,
|
||||
GroupLevels: groupLevels,
|
||||
MainGroup: mainGroup,
|
||||
Fields: fields,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[ProductPerformance][grouped-filter-options] failed trace_id=%s mode=%s levels=%v fields=%d: %v", traceID, mode, groupLevels, len(fields), err)
|
||||
http.Error(w, "urun performans filtre secenekleri alinamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(options)
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSVQuery(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
|
||||
@@ -0,0 +1,1140 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bssapp-backend/models"
|
||||
"bssapp-backend/queries"
|
||||
"bssapp-backend/utils"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type productPerformanceExcelExportRequest struct {
|
||||
Filters map[string][]string `json:"filters"`
|
||||
SortBy string `json:"sort_by"`
|
||||
Descending *bool `json:"descending"`
|
||||
}
|
||||
|
||||
type productPerformanceExcelColumn struct {
|
||||
Header string
|
||||
Kind string
|
||||
Value func(*productPerformanceExcelVariant, productPerformanceExcelStats) any
|
||||
}
|
||||
|
||||
type productPerformanceExcelStats struct {
|
||||
AvgSalesUSD90 float64
|
||||
AvgSalesUSD180 float64
|
||||
AvgSalesUSD365 float64
|
||||
AvgSalesUSDTotal float64
|
||||
}
|
||||
|
||||
type productPerformanceExcelVariant struct {
|
||||
KpiDate string
|
||||
ProductCode string
|
||||
ColorCode string
|
||||
ColorDescription string
|
||||
YakaKodu string
|
||||
ItemDescription string
|
||||
Kategori string
|
||||
Seri string
|
||||
YasGrubu string
|
||||
AskiliYan string
|
||||
UrunIlkGrubu string
|
||||
UrunAnaGrubu string
|
||||
UrunAltGrubu string
|
||||
StockQty float64
|
||||
AvgStock90 float64
|
||||
AvgStock180 float64
|
||||
AvgStock365 float64
|
||||
AvgStockTotal float64
|
||||
BasePriceUSD float64
|
||||
CostPriceUSD float64
|
||||
|
||||
SalesQty90 float64
|
||||
SalesQty180 float64
|
||||
SalesQty365 float64
|
||||
SalesUSD90 float64
|
||||
SalesUSD180 float64
|
||||
SalesUSD365 float64
|
||||
|
||||
SalesQtyTotalProduct float64
|
||||
SalesUSDTotalProduct float64
|
||||
SalesQtyTotalGeneral float64
|
||||
SalesUSDTotalGeneral float64
|
||||
HasGeneralTotal bool
|
||||
|
||||
MarketCount90 int
|
||||
CustomerCount90 int
|
||||
MarketCountTotal int
|
||||
CustomerCountTotal int
|
||||
Markets90 map[string]bool
|
||||
MarketsTotal map[string]bool
|
||||
MarketFiltered bool
|
||||
|
||||
PerformanceBucket string
|
||||
Recommendation string
|
||||
LastSaleDate string
|
||||
LastRefNumber string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func ExportProductPerformanceExcelHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var req productPerformanceExcelExportRequest
|
||||
if r.Method == http.MethodPost {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "gecersiz excel istegi: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
productRows, _, err := queries.ListProductPerformance(ctx, pg, queries.ProductPerformanceFilters{
|
||||
Limit: 50000,
|
||||
Page: 1,
|
||||
SortBy: "product_code",
|
||||
Descending: false,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "urun performans excel listesi alinamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
generalRows, err := queries.ListProductPerformanceGeneral(ctx, pg, 50000)
|
||||
if err != nil {
|
||||
http.Error(w, "urun performans genel excel listesi alinamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rows := buildProductPerformanceExcelVariants(productRows, generalRows, req.Filters)
|
||||
sortProductPerformanceExcelVariants(rows, req.SortBy, req.Descending == nil || *req.Descending)
|
||||
stats := productPerformanceExcelStatsFor(rows)
|
||||
|
||||
file, err := buildProductPerformanceExcelFile(rows, stats)
|
||||
if err != nil {
|
||||
http.Error(w, "urun performans excel dosyasi hazirlanamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
buf, err := file.WriteToBuffer()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("product_performance_renk_yaka_%s.xlsx", time.Now().Format("20060102_150405"))
|
||||
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(buf.Bytes())))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func buildProductPerformanceExcelVariants(productRows []models.ProductPerformanceRow, generalRows []models.ProductPerformanceGeneralRow, filters map[string][]string) []*productPerformanceExcelVariant {
|
||||
byKey := map[string]*productPerformanceExcelVariant{}
|
||||
marketFiltered := productPerformanceExcelHasFilter(filters, "market_key")
|
||||
for _, row := range productRows {
|
||||
if !productPerformanceExcelProductRowMatchesFilters(row, filters) {
|
||||
continue
|
||||
}
|
||||
key := productPerformanceExcelVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)
|
||||
v := byKey[key]
|
||||
if v == nil {
|
||||
v = &productPerformanceExcelVariant{Markets90: map[string]bool{}, MarketsTotal: map[string]bool{}, MarketFiltered: marketFiltered}
|
||||
byKey[key] = v
|
||||
}
|
||||
v.addProductRow(row)
|
||||
}
|
||||
for _, row := range generalRows {
|
||||
if !productPerformanceExcelGeneralRowMatchesFilters(row, filters) {
|
||||
continue
|
||||
}
|
||||
key := productPerformanceExcelVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)
|
||||
v := byKey[key]
|
||||
if v == nil {
|
||||
v = &productPerformanceExcelVariant{Markets90: map[string]bool{}, MarketsTotal: map[string]bool{}, MarketFiltered: marketFiltered}
|
||||
byKey[key] = v
|
||||
}
|
||||
v.addGeneralRow(row)
|
||||
}
|
||||
|
||||
out := make([]*productPerformanceExcelVariant, 0, len(byKey))
|
||||
for _, row := range byKey {
|
||||
if productPerformanceExcelVariantMatchesPostFilters(row, filters) {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v *productPerformanceExcelVariant) addProductRow(row models.ProductPerformanceRow) {
|
||||
v.fillDimensions(
|
||||
row.KpiDate, row.ProductCode, row.ColorCode, row.ColorDescription, row.YakaKodu, row.ItemDescription,
|
||||
row.Kategori, row.Seri, row.YasGrubu, row.AskiliYan, row.UrunIlkGrubu, row.UrunAnaGrubu, row.UrunAltGrubu,
|
||||
row.BasePriceUSD, row.CostPriceUSD, row.StockQty, row.LastSaleDate, row.LastRefNumber, row.UpdatedAt,
|
||||
)
|
||||
v.SalesQty90 += row.SalesQty90
|
||||
v.SalesQty180 += row.SalesQty180
|
||||
v.SalesQty365 += row.SalesQty365
|
||||
v.SalesUSD90 += row.SalesUSD90
|
||||
v.SalesUSD180 += row.SalesUSD180
|
||||
v.SalesUSD365 += row.SalesUSD365
|
||||
v.AvgStock90 = productPerformanceExcelFirstNonZero(v.AvgStock90, row.AvgStock90)
|
||||
v.AvgStock180 = productPerformanceExcelFirstNonZero(v.AvgStock180, row.AvgStock180)
|
||||
v.AvgStock365 = productPerformanceExcelFirstNonZero(v.AvgStock365, row.AvgStock365)
|
||||
v.AvgStockTotal = productPerformanceExcelFirstNonZero(v.AvgStockTotal, row.AvgStockTotal)
|
||||
v.SalesQtyTotalProduct += row.SalesQtyTotal
|
||||
v.SalesUSDTotalProduct += row.SalesUSDTotal
|
||||
v.MarketCount90 = productPerformanceExcelMaxInt(v.MarketCount90, row.MarketCount90)
|
||||
if row.SalesUSD90 > 0 {
|
||||
v.CustomerCount90 += row.CustomerCount90
|
||||
}
|
||||
v.PerformanceBucket = productPerformanceExcelFirstNonEmpty(v.PerformanceBucket, row.PerformanceBucket)
|
||||
v.Recommendation = productPerformanceExcelFirstNonEmpty(v.Recommendation, row.Recommendation)
|
||||
v.addMarket(row.MarketKey, row.SalesQty90, row.SalesUSD90, row.SalesQtyTotal, row.SalesUSDTotal)
|
||||
}
|
||||
|
||||
func (v *productPerformanceExcelVariant) addGeneralRow(row models.ProductPerformanceGeneralRow) {
|
||||
v.fillDimensions(
|
||||
productPerformanceExcelFirstNonEmpty(row.PeriodEnd, row.PeriodStart), row.ProductCode, row.ColorCode, row.ColorDescription, row.YakaKodu, row.ItemDescription,
|
||||
row.Kategori, row.Seri, row.YasGrubu, row.AskiliYan, row.UrunIlkGrubu, row.UrunAnaGrubu, row.UrunAltGrubu,
|
||||
row.BasePriceUSD, row.CostPriceUSD, row.StockQty, row.LastSaleDate, row.LastRefNumber, "",
|
||||
)
|
||||
v.SalesQtyTotalGeneral += row.SalesQtyTotal
|
||||
v.SalesUSDTotalGeneral += row.SalesUSDTotal
|
||||
v.HasGeneralTotal = true
|
||||
v.MarketCountTotal = productPerformanceExcelMaxInt(v.MarketCountTotal, row.MarketCountTotal)
|
||||
if row.SalesUSDTotal > 0 {
|
||||
v.CustomerCountTotal += row.CustomerCountTotal
|
||||
}
|
||||
v.PerformanceBucket = productPerformanceExcelFirstNonEmpty(v.PerformanceBucket, row.PerformanceBucket)
|
||||
v.Recommendation = productPerformanceExcelFirstNonEmpty(v.Recommendation, row.Recommendation)
|
||||
v.addMarket(row.MarketKey, 0, 0, row.SalesQtyTotal, row.SalesUSDTotal)
|
||||
}
|
||||
|
||||
func (v *productPerformanceExcelVariant) fillDimensions(kpiDate, productCode, colorCode, colorDescription, yakaKodu, itemDescription, kategori, seri, yasGrubu, askiliYan, urunIlkGrubu, urunAnaGrubu, urunAltGrubu string, basePrice, costPrice, stockQty float64, lastSaleDate, lastRefNumber, updatedAt string) {
|
||||
v.KpiDate = productPerformanceExcelFirstNonEmpty(v.KpiDate, kpiDate)
|
||||
v.ProductCode = productPerformanceExcelFirstNonEmpty(v.ProductCode, productCode)
|
||||
v.ColorCode = productPerformanceExcelFirstNonEmpty(v.ColorCode, colorCode)
|
||||
v.ColorDescription = productPerformanceExcelFirstNonEmpty(v.ColorDescription, colorDescription)
|
||||
v.YakaKodu = productPerformanceExcelFirstNonEmpty(v.YakaKodu, yakaKodu)
|
||||
v.ItemDescription = productPerformanceExcelFirstNonEmpty(v.ItemDescription, itemDescription)
|
||||
v.Kategori = productPerformanceExcelFirstNonEmpty(v.Kategori, kategori)
|
||||
v.Seri = productPerformanceExcelFirstNonEmpty(v.Seri, seri)
|
||||
v.YasGrubu = productPerformanceExcelFirstNonEmpty(v.YasGrubu, yasGrubu)
|
||||
v.AskiliYan = productPerformanceExcelFirstNonEmpty(v.AskiliYan, productPerformanceExcelCleanOptional(askiliYan))
|
||||
v.UrunIlkGrubu = productPerformanceExcelFirstNonEmpty(v.UrunIlkGrubu, productPerformanceExcelCleanOptional(urunIlkGrubu))
|
||||
v.UrunAnaGrubu = productPerformanceExcelFirstNonEmpty(v.UrunAnaGrubu, urunAnaGrubu)
|
||||
v.UrunAltGrubu = productPerformanceExcelFirstNonEmpty(v.UrunAltGrubu, urunAltGrubu)
|
||||
v.BasePriceUSD = productPerformanceExcelFirstNonZero(v.BasePriceUSD, basePrice)
|
||||
v.CostPriceUSD = productPerformanceExcelFirstNonZero(v.CostPriceUSD, costPrice)
|
||||
v.CostPriceUSD, v.BasePriceUSD = productPerformanceExcelNormalizeCostPair(v.CostPriceUSD, v.BasePriceUSD)
|
||||
if stockQty > v.StockQty {
|
||||
v.StockQty = stockQty
|
||||
}
|
||||
if lastSaleDate > v.LastSaleDate {
|
||||
v.LastSaleDate = lastSaleDate
|
||||
v.LastRefNumber = lastRefNumber
|
||||
}
|
||||
if updatedAt > v.UpdatedAt {
|
||||
v.UpdatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
func (v *productPerformanceExcelVariant) addMarket(marketKey string, salesQty90, salesUSD90, salesQtyTotal, salesUSDTotal float64) {
|
||||
market := productPerformanceExcelMarketName(marketKey)
|
||||
if market == "" || market == "STOK" {
|
||||
return
|
||||
}
|
||||
if salesUSD90 > 0 {
|
||||
v.Markets90[market] = true
|
||||
}
|
||||
if salesUSDTotal > 0 {
|
||||
v.MarketsTotal[market] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (v *productPerformanceExcelVariant) totalQty() float64 {
|
||||
if v.HasGeneralTotal {
|
||||
return v.SalesQtyTotalGeneral
|
||||
}
|
||||
return v.SalesQtyTotalProduct
|
||||
}
|
||||
|
||||
func (v *productPerformanceExcelVariant) totalUSD() float64 {
|
||||
if v.HasGeneralTotal {
|
||||
return v.SalesUSDTotalGeneral
|
||||
}
|
||||
return v.SalesUSDTotalProduct
|
||||
}
|
||||
|
||||
func (v *productPerformanceExcelVariant) marketCount90() int {
|
||||
if v.MarketFiltered {
|
||||
return len(v.Markets90)
|
||||
}
|
||||
return productPerformanceExcelMaxInt(v.MarketCount90, len(v.Markets90))
|
||||
}
|
||||
|
||||
func (v *productPerformanceExcelVariant) marketCountTotal() int {
|
||||
if v.MarketFiltered {
|
||||
return len(v.MarketsTotal)
|
||||
}
|
||||
return productPerformanceExcelMaxInt(v.MarketCountTotal, len(v.MarketsTotal))
|
||||
}
|
||||
|
||||
func (v *productPerformanceExcelVariant) marketsLabel() string {
|
||||
return productPerformanceExcelSortedSetLabel(v.MarketsTotal)
|
||||
}
|
||||
|
||||
func buildProductPerformanceExcelFile(rows []*productPerformanceExcelVariant, stats productPerformanceExcelStats) (*excelize.File, error) {
|
||||
f := excelize.NewFile()
|
||||
sheet := "RenkYaka"
|
||||
f.SetSheetName("Sheet1", sheet)
|
||||
|
||||
columns := productPerformanceExcelColumns()
|
||||
headerStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"1F4E78"}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
|
||||
})
|
||||
textStyle, _ := f.NewStyle(&excelize.Style{NumFmt: 49})
|
||||
intStyle, _ := f.NewStyle(&excelize.Style{NumFmt: 3})
|
||||
decimalStyle, _ := f.NewStyle(&excelize.Style{NumFmt: 4})
|
||||
percentStyle, _ := f.NewStyle(&excelize.Style{NumFmt: 10})
|
||||
|
||||
for i, col := range columns {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
_ = f.SetCellStr(sheet, cell, col.Header)
|
||||
}
|
||||
if len(columns) > 0 {
|
||||
lastHeader, _ := excelize.CoordinatesToCellName(len(columns), 1)
|
||||
_ = f.SetCellStyle(sheet, "A1", lastHeader, headerStyle)
|
||||
}
|
||||
|
||||
for rowIndex, row := range rows {
|
||||
excelRow := rowIndex + 2
|
||||
for colIndex, col := range columns {
|
||||
cell, _ := excelize.CoordinatesToCellName(colIndex+1, excelRow)
|
||||
value := col.Value(row, stats)
|
||||
if col.Kind == "text" {
|
||||
_ = f.SetCellStr(sheet, cell, strings.TrimSpace(fmt.Sprint(value)))
|
||||
} else {
|
||||
_ = f.SetCellValue(sheet, cell, productPerformanceExcelFloat(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastRow := productPerformanceExcelMaxInt(len(rows)+1, 2)
|
||||
if len(columns) > 0 {
|
||||
lastFilterCell, _ := excelize.CoordinatesToCellName(len(columns), lastRow)
|
||||
_ = f.AutoFilter(sheet, "A1:"+lastFilterCell, []excelize.AutoFilterOptions{})
|
||||
}
|
||||
for i, col := range columns {
|
||||
colName, _ := excelize.ColumnNumberToName(i + 1)
|
||||
_ = f.SetColWidth(sheet, colName, colName, productPerformanceExcelColumnWidth(col.Header, col.Kind))
|
||||
style := decimalStyle
|
||||
switch col.Kind {
|
||||
case "text":
|
||||
style = textStyle
|
||||
case "int":
|
||||
style = intStyle
|
||||
case "percent":
|
||||
style = percentStyle
|
||||
}
|
||||
_ = f.SetCellStyle(sheet, fmt.Sprintf("%s2", colName), fmt.Sprintf("%s%d", colName, lastRow), style)
|
||||
}
|
||||
_ = f.SetPanes(sheet, &excelize.Panes{Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft"})
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func productPerformanceExcelColumns() []productPerformanceExcelColumn {
|
||||
base := []productPerformanceExcelColumn{
|
||||
textExcelColumn("KPI Tarihi", func(v *productPerformanceExcelVariant) any { return v.KpiDate }),
|
||||
textExcelColumn("Ürün İlk Grubu", func(v *productPerformanceExcelVariant) any { return v.UrunIlkGrubu }),
|
||||
textExcelColumn("Askılı/Yan", func(v *productPerformanceExcelVariant) any { return v.AskiliYan }),
|
||||
textExcelColumn("Kategori", func(v *productPerformanceExcelVariant) any { return v.Kategori }),
|
||||
textExcelColumn("Ürün Ana Grubu", func(v *productPerformanceExcelVariant) any { return v.UrunAnaGrubu }),
|
||||
textExcelColumn("Ürün Alt Grubu", func(v *productPerformanceExcelVariant) any { return v.UrunAltGrubu }),
|
||||
textExcelColumn("Ürün", func(v *productPerformanceExcelVariant) any { return v.ProductCode }),
|
||||
textExcelColumn("Açıklama", func(v *productPerformanceExcelVariant) any { return v.ItemDescription }),
|
||||
textExcelColumn("Renk", func(v *productPerformanceExcelVariant) any { return v.ColorCode }),
|
||||
textExcelColumn("Renk Açıklama", func(v *productPerformanceExcelVariant) any { return v.ColorDescription }),
|
||||
textExcelColumn("Yaka", func(v *productPerformanceExcelVariant) any { return v.YakaKodu }),
|
||||
textExcelColumn("Renk/Yaka", func(v *productPerformanceExcelVariant) any {
|
||||
return productPerformanceExcelColorYaka(v.ColorCode, v.ColorDescription, v.YakaKodu)
|
||||
}),
|
||||
textExcelColumn("Piyasalar", func(v *productPerformanceExcelVariant) any { return v.marketsLabel() }),
|
||||
numberExcelColumn("Toplam Stok", "int", func(v *productPerformanceExcelVariant) any { return v.StockQty }),
|
||||
}
|
||||
base = append(base, productPerformanceExcelPeriodColumns("90G", "90d", func(v *productPerformanceExcelVariant) (float64, float64, int, int) {
|
||||
return v.SalesQty90, v.SalesUSD90, v.marketCount90(), v.CustomerCount90
|
||||
})...)
|
||||
base = append(base, productPerformanceExcelPeriodColumns("180G", "180d", func(v *productPerformanceExcelVariant) (float64, float64, int, int) {
|
||||
return v.SalesQty180, v.SalesUSD180, v.marketCount90(), v.CustomerCount90
|
||||
})...)
|
||||
base = append(base, productPerformanceExcelPeriodColumns("360G", "365d", func(v *productPerformanceExcelVariant) (float64, float64, int, int) {
|
||||
return v.SalesQty365, v.SalesUSD365, v.marketCount90(), v.CustomerCount90
|
||||
})...)
|
||||
base = append(base, productPerformanceExcelPeriodColumns("Genel", "total", func(v *productPerformanceExcelVariant) (float64, float64, int, int) {
|
||||
return v.totalQty(), v.totalUSD(), v.marketCountTotal(), v.CustomerCountTotal
|
||||
})...)
|
||||
base = append(base,
|
||||
textExcelColumn("Durum", func(v *productPerformanceExcelVariant) any { return productPerformanceExcelStatus(v) }),
|
||||
textExcelColumn("Öneri", func(v *productPerformanceExcelVariant) any { return productPerformanceExcelRecommendation(v) }),
|
||||
textExcelColumn("Son Satış", func(v *productPerformanceExcelVariant) any { return v.LastSaleDate }),
|
||||
textExcelColumn("Son Ref", func(v *productPerformanceExcelVariant) any { return v.LastRefNumber }),
|
||||
textExcelColumn("Güncelleme", func(v *productPerformanceExcelVariant) any { return v.UpdatedAt }),
|
||||
)
|
||||
return base
|
||||
}
|
||||
|
||||
func productPerformanceExcelPeriodColumns(label, suffix string, values func(*productPerformanceExcelVariant) (float64, float64, int, int)) []productPerformanceExcelColumn {
|
||||
return []productPerformanceExcelColumn{
|
||||
numberExcelColumn(label+" Toplam Adet", "int", func(v *productPerformanceExcelVariant) any {
|
||||
qty, _, _, _ := values(v)
|
||||
return qty
|
||||
}),
|
||||
numberExcelColumn(label+" Toplam Ciro USD", "number", func(v *productPerformanceExcelVariant) any {
|
||||
_, usd, _, _ := values(v)
|
||||
return usd
|
||||
}),
|
||||
numberExcelColumn(label+" Ort. Satış Fiyatı USD", "number", func(v *productPerformanceExcelVariant) any {
|
||||
qty, usd, _, _ := values(v)
|
||||
return productPerformanceExcelAvgPrice(usd, qty)
|
||||
}),
|
||||
numberExcelColumn(label+" Yıllık Stok Devir", "number", func(v *productPerformanceExcelVariant) any {
|
||||
qty, _, _, _ := values(v)
|
||||
return productPerformanceExcelStockTurnover(qty, productPerformanceExcelAvgStock(v, suffix), productPerformanceExcelPeriodDays(v, suffix))
|
||||
}),
|
||||
numberExcelColumn(label+" Taban Maliyet USD", "number", func(v *productPerformanceExcelVariant) any { return v.BasePriceUSD }),
|
||||
numberExcelColumn(label+" Çıplak Maliyet USD", "number", func(v *productPerformanceExcelVariant) any { return v.CostPriceUSD }),
|
||||
numberExcelColumn(label+" Taban Toplam K/Z USD", "number", func(v *productPerformanceExcelVariant) any {
|
||||
qty, usd, _, _ := values(v)
|
||||
return productPerformanceExcelGrossProfit(usd, qty, v.BasePriceUSD)
|
||||
}),
|
||||
numberExcelColumn(label+" Çıplak Toplam K/Z USD", "number", func(v *productPerformanceExcelVariant) any {
|
||||
qty, usd, _, _ := values(v)
|
||||
return productPerformanceExcelGrossProfit(usd, qty, v.CostPriceUSD)
|
||||
}),
|
||||
numberExcelColumn(label+" Taban Marj", "percent", func(v *productPerformanceExcelVariant) any {
|
||||
qty, usd, _, _ := values(v)
|
||||
return productPerformanceExcelMargin(usd, qty, v.BasePriceUSD)
|
||||
}),
|
||||
numberExcelColumn(label+" Çıplak Marj", "percent", func(v *productPerformanceExcelVariant) any {
|
||||
qty, usd, _, _ := values(v)
|
||||
return productPerformanceExcelMargin(usd, qty, v.CostPriceUSD)
|
||||
}),
|
||||
numberExcelColumn(label+" Tekil Piyasa", "int", func(v *productPerformanceExcelVariant) any {
|
||||
_, _, markets, _ := values(v)
|
||||
return markets
|
||||
}),
|
||||
numberExcelColumn(label+" Tekil Müşteri", "int", func(v *productPerformanceExcelVariant) any {
|
||||
_, _, _, customers := values(v)
|
||||
return customers
|
||||
}),
|
||||
numberExcelColumn(label+" Ürün Skor", "number", func(v *productPerformanceExcelVariant) any {
|
||||
qty, usd, markets, customers := values(v)
|
||||
days := productPerformanceExcelPeriodDays(v, suffix)
|
||||
return productPerformanceExcelProductScore(suffix, usd, productPerformanceExcelStockTurnover(qty, productPerformanceExcelAvgStock(v, suffix), days), float64(markets), float64(customers), productPerformanceExcelMargin(usd, qty, v.CostPriceUSD), days)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func textExcelColumn(header string, value func(*productPerformanceExcelVariant) any) productPerformanceExcelColumn {
|
||||
return productPerformanceExcelColumn{Header: header, Kind: "text", Value: func(v *productPerformanceExcelVariant, _ productPerformanceExcelStats) any { return value(v) }}
|
||||
}
|
||||
|
||||
func numberExcelColumn(header, kind string, value func(*productPerformanceExcelVariant) any) productPerformanceExcelColumn {
|
||||
return productPerformanceExcelColumn{Header: header, Kind: kind, Value: func(v *productPerformanceExcelVariant, _ productPerformanceExcelStats) any { return value(v) }}
|
||||
}
|
||||
|
||||
func productPerformanceExcelProductRowMatchesFilters(row models.ProductPerformanceRow, filters map[string][]string) bool {
|
||||
for field, selected := range productPerformanceExcelFilterSets(filters) {
|
||||
if field == "performance_bucket" {
|
||||
continue
|
||||
}
|
||||
if !productPerformanceExcelMatchesAny(selected, productPerformanceExcelProductFilterCandidates(row, field)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func productPerformanceExcelGeneralRowMatchesFilters(row models.ProductPerformanceGeneralRow, filters map[string][]string) bool {
|
||||
for field, selected := range productPerformanceExcelFilterSets(filters) {
|
||||
if field == "performance_bucket" {
|
||||
continue
|
||||
}
|
||||
if !productPerformanceExcelMatchesAny(selected, productPerformanceExcelGeneralFilterCandidates(row, field)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func productPerformanceExcelVariantMatchesPostFilters(row *productPerformanceExcelVariant, filters map[string][]string) bool {
|
||||
selected := productPerformanceExcelFilterSets(filters)["performance_bucket"]
|
||||
if len(selected) == 0 {
|
||||
return true
|
||||
}
|
||||
return productPerformanceExcelMatchesAny(selected, []string{row.PerformanceBucket, productPerformanceExcelStatus(row)})
|
||||
}
|
||||
|
||||
func productPerformanceExcelFilterSets(filters map[string][]string) map[string]map[string]bool {
|
||||
out := map[string]map[string]bool{}
|
||||
for field, values := range filters {
|
||||
field = strings.TrimSpace(field)
|
||||
for _, value := range values {
|
||||
value = productPerformanceExcelNormalize(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if out[field] == nil {
|
||||
out[field] = map[string]bool{}
|
||||
}
|
||||
out[field][value] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func productPerformanceExcelHasFilter(filters map[string][]string, field string) bool {
|
||||
for _, value := range filters[strings.TrimSpace(field)] {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func productPerformanceExcelProductFilterCandidates(row models.ProductPerformanceRow, field string) []string {
|
||||
switch strings.TrimSpace(field) {
|
||||
case "product_code":
|
||||
return []string{row.ProductCode}
|
||||
case "item_description":
|
||||
return []string{row.ItemDescription}
|
||||
case "color_code":
|
||||
return []string{row.ColorCode}
|
||||
case "yaka_kodu":
|
||||
return []string{row.YakaKodu}
|
||||
case "color_yaka":
|
||||
return []string{productPerformanceExcelColorYaka(row.ColorCode, row.ColorDescription, row.YakaKodu), row.ColorCode + "/" + row.YakaKodu}
|
||||
case "kategori":
|
||||
return []string{row.Kategori}
|
||||
case "askili_yan":
|
||||
return []string{productPerformanceExcelCleanOptional(row.AskiliYan)}
|
||||
case "urun_ilk_grubu":
|
||||
return []string{productPerformanceExcelCleanOptional(row.UrunIlkGrubu)}
|
||||
case "urun_ana_grubu":
|
||||
return []string{row.UrunAnaGrubu}
|
||||
case "urun_alt_grubu":
|
||||
return []string{row.UrunAltGrubu}
|
||||
case "market_key":
|
||||
return []string{row.MarketKey, productPerformanceExcelMarketName(row.MarketKey)}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelGeneralFilterCandidates(row models.ProductPerformanceGeneralRow, field string) []string {
|
||||
switch strings.TrimSpace(field) {
|
||||
case "product_code":
|
||||
return []string{row.ProductCode}
|
||||
case "item_description":
|
||||
return []string{row.ItemDescription}
|
||||
case "color_code":
|
||||
return []string{row.ColorCode}
|
||||
case "yaka_kodu":
|
||||
return []string{row.YakaKodu}
|
||||
case "color_yaka":
|
||||
return []string{productPerformanceExcelColorYaka(row.ColorCode, row.ColorDescription, row.YakaKodu), row.ColorCode + "/" + row.YakaKodu}
|
||||
case "kategori":
|
||||
return []string{row.Kategori}
|
||||
case "askili_yan":
|
||||
return []string{productPerformanceExcelCleanOptional(row.AskiliYan)}
|
||||
case "urun_ilk_grubu":
|
||||
return []string{productPerformanceExcelCleanOptional(row.UrunIlkGrubu)}
|
||||
case "urun_ana_grubu":
|
||||
return []string{row.UrunAnaGrubu}
|
||||
case "urun_alt_grubu":
|
||||
return []string{row.UrunAltGrubu}
|
||||
case "market_key":
|
||||
return []string{row.MarketKey, productPerformanceExcelMarketName(row.MarketKey)}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelMatchesAny(selected map[string]bool, candidates []string) bool {
|
||||
if len(selected) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if selected[productPerformanceExcelNormalize(candidate)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sortProductPerformanceExcelVariants(rows []*productPerformanceExcelVariant, sortBy string, desc bool) {
|
||||
sortBy = strings.TrimSpace(sortBy)
|
||||
if sortBy == "" || sortBy == "image" {
|
||||
sortBy = "product_code"
|
||||
desc = false
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
cmp := productPerformanceExcelCompare(rows[i], rows[j], sortBy)
|
||||
if cmp == 0 {
|
||||
cmp = strings.Compare(productPerformanceExcelHierarchySortKey(rows[i]), productPerformanceExcelHierarchySortKey(rows[j]))
|
||||
}
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
}
|
||||
|
||||
func productPerformanceExcelCompare(a, b *productPerformanceExcelVariant, sortBy string) int {
|
||||
if productPerformanceExcelIsNumericSort(sortBy) {
|
||||
av := productPerformanceExcelSortNumber(a, sortBy)
|
||||
bv := productPerformanceExcelSortNumber(b, sortBy)
|
||||
if av < bv {
|
||||
return -1
|
||||
}
|
||||
if av > bv {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return strings.Compare(productPerformanceExcelNormalize(productPerformanceExcelSortText(a, sortBy)), productPerformanceExcelNormalize(productPerformanceExcelSortText(b, sortBy)))
|
||||
}
|
||||
|
||||
func productPerformanceExcelIsNumericSort(sortBy string) bool {
|
||||
switch sortBy {
|
||||
case "stock_qty", "sales_qty_90d", "sales_qty_180d", "sales_qty_365d", "sales_qty_total",
|
||||
"sales_usd_90d", "sales_usd_180d", "sales_usd_365d", "sales_usd_total",
|
||||
"avg_price_usd_90d", "avg_price_usd_180d", "avg_price_usd_365d", "avg_price_usd_total",
|
||||
"stock_turnover_90d", "stock_turnover_180d", "stock_turnover_365d", "stock_turnover_total",
|
||||
"base_price_usd", "cost_price_usd", "market_count_90d", "customer_count_90d", "market_count_total", "customer_count_total",
|
||||
"performance_score", "performance_score_90d", "performance_score_total":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelSortNumber(row *productPerformanceExcelVariant, sortBy string) float64 {
|
||||
switch sortBy {
|
||||
case "stock_qty":
|
||||
return row.StockQty
|
||||
case "sales_qty_90d":
|
||||
return row.SalesQty90
|
||||
case "sales_qty_180d":
|
||||
return row.SalesQty180
|
||||
case "sales_qty_365d":
|
||||
return row.SalesQty365
|
||||
case "sales_qty_total":
|
||||
return row.totalQty()
|
||||
case "sales_usd_90d":
|
||||
return row.SalesUSD90
|
||||
case "sales_usd_180d":
|
||||
return row.SalesUSD180
|
||||
case "sales_usd_365d":
|
||||
return row.SalesUSD365
|
||||
case "sales_usd_total":
|
||||
return row.totalUSD()
|
||||
case "avg_price_usd_90d":
|
||||
return productPerformanceExcelAvgPrice(row.SalesUSD90, row.SalesQty90)
|
||||
case "avg_price_usd_180d":
|
||||
return productPerformanceExcelAvgPrice(row.SalesUSD180, row.SalesQty180)
|
||||
case "avg_price_usd_365d":
|
||||
return productPerformanceExcelAvgPrice(row.SalesUSD365, row.SalesQty365)
|
||||
case "avg_price_usd_total":
|
||||
return productPerformanceExcelAvgPrice(row.totalUSD(), row.totalQty())
|
||||
case "stock_turnover_90d":
|
||||
return productPerformanceExcelStockTurnover(row.SalesQty90, productPerformanceExcelAvgStock(row, "90d"), productPerformanceExcelPeriodDays(row, "90d"))
|
||||
case "stock_turnover_180d":
|
||||
return productPerformanceExcelStockTurnover(row.SalesQty180, productPerformanceExcelAvgStock(row, "180d"), productPerformanceExcelPeriodDays(row, "180d"))
|
||||
case "stock_turnover_365d":
|
||||
return productPerformanceExcelStockTurnover(row.SalesQty365, productPerformanceExcelAvgStock(row, "365d"), productPerformanceExcelPeriodDays(row, "365d"))
|
||||
case "stock_turnover_total":
|
||||
return productPerformanceExcelStockTurnover(row.totalQty(), productPerformanceExcelAvgStock(row, "total"), productPerformanceExcelPeriodDays(row, "total"))
|
||||
case "base_price_usd":
|
||||
return row.BasePriceUSD
|
||||
case "cost_price_usd":
|
||||
return row.CostPriceUSD
|
||||
case "market_count_90d":
|
||||
return float64(row.marketCount90())
|
||||
case "customer_count_90d":
|
||||
return float64(row.CustomerCount90)
|
||||
case "market_count_total":
|
||||
return float64(row.marketCountTotal())
|
||||
case "customer_count_total":
|
||||
return float64(row.CustomerCountTotal)
|
||||
case "performance_score", "performance_score_90d":
|
||||
days := productPerformanceExcelPeriodDays(row, "90d")
|
||||
return productPerformanceExcelProductScore("90d", row.SalesUSD90, productPerformanceExcelStockTurnover(row.SalesQty90, productPerformanceExcelAvgStock(row, "90d"), days), float64(row.marketCount90()), float64(row.CustomerCount90), productPerformanceExcelMargin(row.SalesUSD90, row.SalesQty90, row.CostPriceUSD), days)
|
||||
case "performance_score_total":
|
||||
days := productPerformanceExcelPeriodDays(row, "total")
|
||||
return productPerformanceExcelProductScore("total", row.totalUSD(), productPerformanceExcelStockTurnover(row.totalQty(), productPerformanceExcelAvgStock(row, "total"), days), float64(row.marketCountTotal()), float64(row.CustomerCountTotal), productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD), days)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelSortText(row *productPerformanceExcelVariant, sortBy string) string {
|
||||
switch sortBy {
|
||||
case "product_code":
|
||||
return row.ProductCode
|
||||
case "color_yaka":
|
||||
return productPerformanceExcelColorYaka(row.ColorCode, row.ColorDescription, row.YakaKodu)
|
||||
case "color_code":
|
||||
return row.ColorCode
|
||||
case "yaka_kodu":
|
||||
return row.YakaKodu
|
||||
case "item_description":
|
||||
return row.ItemDescription
|
||||
case "kategori":
|
||||
return row.Kategori
|
||||
case "askili_yan":
|
||||
return row.AskiliYan
|
||||
case "urun_ilk_grubu":
|
||||
return row.UrunIlkGrubu
|
||||
case "urun_ana_grubu":
|
||||
return row.UrunAnaGrubu
|
||||
case "urun_alt_grubu":
|
||||
return row.UrunAltGrubu
|
||||
case "market_key":
|
||||
return row.marketsLabel()
|
||||
case "performance_bucket":
|
||||
return productPerformanceExcelStatus(row)
|
||||
default:
|
||||
return productPerformanceExcelHierarchySortKey(row)
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelStatsFor(rows []*productPerformanceExcelVariant) productPerformanceExcelStats {
|
||||
var stats productPerformanceExcelStats
|
||||
var c90, c180, c365, cTotal float64
|
||||
for _, row := range rows {
|
||||
if row.SalesUSD90 > 0 {
|
||||
stats.AvgSalesUSD90 += row.SalesUSD90
|
||||
c90++
|
||||
}
|
||||
if row.SalesUSD180 > 0 {
|
||||
stats.AvgSalesUSD180 += row.SalesUSD180
|
||||
c180++
|
||||
}
|
||||
if row.SalesUSD365 > 0 {
|
||||
stats.AvgSalesUSD365 += row.SalesUSD365
|
||||
c365++
|
||||
}
|
||||
if row.totalUSD() > 0 {
|
||||
stats.AvgSalesUSDTotal += row.totalUSD()
|
||||
cTotal++
|
||||
}
|
||||
}
|
||||
if c90 > 0 {
|
||||
stats.AvgSalesUSD90 /= c90
|
||||
}
|
||||
if c180 > 0 {
|
||||
stats.AvgSalesUSD180 /= c180
|
||||
}
|
||||
if c365 > 0 {
|
||||
stats.AvgSalesUSD365 /= c365
|
||||
}
|
||||
if cTotal > 0 {
|
||||
stats.AvgSalesUSDTotal /= cTotal
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
func productPerformanceExcelVariantKey(productCode, colorCode, yakaKodu string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(productCode)) + "|" + strings.ToUpper(strings.TrimSpace(colorCode)) + "|" + strings.ToUpper(strings.TrimSpace(yakaKodu))
|
||||
}
|
||||
|
||||
func productPerformanceExcelHierarchySortKey(row *productPerformanceExcelVariant) string {
|
||||
return strings.Join([]string{row.UrunIlkGrubu, row.AskiliYan, row.Kategori, row.UrunAnaGrubu, row.UrunAltGrubu, row.ProductCode, row.ColorCode, row.YakaKodu}, "|")
|
||||
}
|
||||
|
||||
func productPerformanceExcelColorYaka(colorCode, colorDescription, yakaKodu string) string {
|
||||
color := strings.TrimSpace(colorCode)
|
||||
if desc := strings.TrimSpace(colorDescription); color != "" && desc != "" {
|
||||
color = color + "-" + strings.ToUpper(desc)
|
||||
}
|
||||
yaka := strings.TrimSpace(yakaKodu)
|
||||
parts := make([]string, 0, 2)
|
||||
if color != "" && color != "-" {
|
||||
parts = append(parts, color)
|
||||
}
|
||||
if yaka != "" && yaka != "-" {
|
||||
parts = append(parts, yaka)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func productPerformanceExcelMarketName(value string) string {
|
||||
parts := strings.Split(value, "|")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := strings.TrimSpace(parts[i])
|
||||
if part != "" {
|
||||
return part
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func productPerformanceExcelStatus(row *productPerformanceExcelVariant) string {
|
||||
switch {
|
||||
case row.totalQty() > 0 && row.totalUSD() <= 0:
|
||||
return "Ciro/Fiyat Kontrol"
|
||||
case row.totalQty() <= 0 && row.StockQty > 0:
|
||||
return "Stok Riski"
|
||||
case productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD) < 0:
|
||||
return "Fiyat Baskısı"
|
||||
case productPerformanceExcelProductScore("total", row.totalUSD(), productPerformanceExcelStockTurnover(row.totalQty(), productPerformanceExcelAvgStock(row, "total"), productPerformanceExcelPeriodDays(row, "total")), float64(row.marketCountTotal()), float64(row.CustomerCountTotal), productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD), productPerformanceExcelPeriodDays(row, "total")) >= 70:
|
||||
return "Yıldız Ürün"
|
||||
default:
|
||||
return productPerformanceExcelBucketLabel(row.PerformanceBucket)
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelRecommendation(row *productPerformanceExcelVariant) string {
|
||||
switch {
|
||||
case row.totalQty() > 0 && row.totalUSD() <= 0:
|
||||
return "Satış adedi var ama USD ciro/fiyat yok. Satış tutarı aktarımı ve döviz dönüşümü kontrol edilmeli."
|
||||
case row.totalQty() <= 0 && row.StockQty > 0:
|
||||
return "Satış yok, stok duruyor. Piyasa/fiyat aksiyonu gerekli."
|
||||
case row.Recommendation != "":
|
||||
return row.Recommendation
|
||||
default:
|
||||
return "Düzenli takip."
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelBucketLabel(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "YILDIZ_URUN":
|
||||
return "Yıldız Ürün"
|
||||
case "STOKSUZ_TALEP":
|
||||
return "Stoksuz Talep"
|
||||
case "STOK_RISKI":
|
||||
return "Stok Riski"
|
||||
case "FIYAT_BASKISI":
|
||||
return "Fiyat Baskısı"
|
||||
case "FIYAT_FIRSATI":
|
||||
return "Fiyat Fırsatı"
|
||||
case "MALIYET_YOK":
|
||||
return "Maliyet Yok"
|
||||
case "TAKIP":
|
||||
return "Takip"
|
||||
default:
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelCleanOptional(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "-" {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func productPerformanceExcelNormalize(value string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func productPerformanceExcelFirstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func productPerformanceExcelFirstNonZero(values ...float64) float64 {
|
||||
for _, value := range values {
|
||||
if value != 0 && !math.IsNaN(value) && !math.IsInf(value, 0) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func productPerformanceExcelNormalizeCostPair(costPriceUSD, basePriceUSD float64) (float64, float64) {
|
||||
if costPriceUSD > 0 && basePriceUSD > 0 && costPriceUSD > basePriceUSD {
|
||||
return basePriceUSD, costPriceUSD
|
||||
}
|
||||
return costPriceUSD, basePriceUSD
|
||||
}
|
||||
|
||||
func productPerformanceExcelAvgPrice(salesUSD, qty float64) float64 {
|
||||
if qty <= 0 {
|
||||
return 0
|
||||
}
|
||||
return salesUSD / qty
|
||||
}
|
||||
|
||||
const productPerformanceExcelStockTurnoverYearDays = 360.0
|
||||
|
||||
func productPerformanceExcelAvgStock(row *productPerformanceExcelVariant, suffix string) float64 {
|
||||
switch suffix {
|
||||
case "90d":
|
||||
return productPerformanceExcelFirstNonZero(row.AvgStock90, row.StockQty)
|
||||
case "180d":
|
||||
return productPerformanceExcelFirstNonZero(row.AvgStock180, row.StockQty)
|
||||
case "365d":
|
||||
return productPerformanceExcelFirstNonZero(row.AvgStock365, row.StockQty)
|
||||
case "total":
|
||||
return productPerformanceExcelFirstNonZero(row.AvgStockTotal, row.StockQty)
|
||||
default:
|
||||
return row.StockQty
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelPeriodDays(row *productPerformanceExcelVariant, suffix string) float64 {
|
||||
switch suffix {
|
||||
case "90d":
|
||||
return 90
|
||||
case "180d":
|
||||
return 180
|
||||
case "365d":
|
||||
return 360
|
||||
case "total":
|
||||
end, err := time.Parse("2006-01-02", strings.TrimSpace(row.KpiDate))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
start := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
if end.Before(start) {
|
||||
return 0
|
||||
}
|
||||
return end.Sub(start).Hours()/24 + 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelStockTurnover(salesQty, avgStock, periodDays float64) float64 {
|
||||
if salesQty <= 0 || avgStock <= 0 {
|
||||
return 0
|
||||
}
|
||||
raw := salesQty / avgStock
|
||||
if periodDays <= 0 {
|
||||
return raw
|
||||
}
|
||||
return raw * productPerformanceExcelStockTurnoverYearDays / periodDays
|
||||
}
|
||||
|
||||
func productPerformanceExcelGrossProfit(salesUSD, qty, unitCost float64) float64 {
|
||||
if qty <= 0 {
|
||||
return 0
|
||||
}
|
||||
return salesUSD - qty*unitCost
|
||||
}
|
||||
|
||||
func productPerformanceExcelMargin(salesUSD, qty, unitCost float64) float64 {
|
||||
if salesUSD <= 0 {
|
||||
return 0
|
||||
}
|
||||
return productPerformanceExcelGrossProfit(salesUSD, qty, unitCost) / salesUSD
|
||||
}
|
||||
|
||||
func productPerformanceExcelProductScore(suffix string, salesUSD, stockTurnover, marketCount, customerCount, margin float64, periodDays ...float64) float64 {
|
||||
if salesUSD <= 0 {
|
||||
return 1
|
||||
}
|
||||
days := productPerformanceExcelScorePeriodDays(suffix, periodDays...)
|
||||
revenueScore := productPerformanceExcelRatioScore(salesUSD, productPerformanceExcelProductRevenueTargetForDays(suffix, days))
|
||||
score := 0.30*productPerformanceExcelMarginScore(margin) +
|
||||
0.20*productPerformanceExcelRatioScore(stockTurnover, 4) +
|
||||
0.20*revenueScore +
|
||||
0.05*productPerformanceExcelRatioScore(marketCount, productPerformanceExcelMarketSpreadTarget(suffix)) +
|
||||
0.25*productPerformanceExcelRatioScore(customerCount, productPerformanceExcelCustomerSpreadTargetForDays(suffix, days))
|
||||
return productPerformanceExcelRoundScore(score)
|
||||
}
|
||||
|
||||
func productPerformanceExcelMarketSpreadTarget(suffix string) float64 {
|
||||
switch suffix {
|
||||
case "90d":
|
||||
return 3
|
||||
case "180d":
|
||||
return 3
|
||||
case "365d":
|
||||
return 6
|
||||
default:
|
||||
return 6
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelCustomerSpreadTarget(suffix string) float64 {
|
||||
return productPerformanceExcelCustomerSpreadTargetForDays(suffix, productPerformanceExcelScorePeriodDays(suffix))
|
||||
}
|
||||
|
||||
func productPerformanceExcelCustomerSpreadTargetForDays(suffix string, periodDays float64) float64 {
|
||||
switch suffix {
|
||||
case "90d":
|
||||
return 8
|
||||
case "180d":
|
||||
return 20
|
||||
case "365d":
|
||||
return 50
|
||||
default:
|
||||
return 20 * productPerformanceExcelTotalPeriodMultiplier(periodDays)
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelProductRevenueTarget(suffix string) float64 {
|
||||
return productPerformanceExcelProductRevenueTargetForDays(suffix, productPerformanceExcelScorePeriodDays(suffix))
|
||||
}
|
||||
|
||||
func productPerformanceExcelProductRevenueTargetForDays(suffix string, periodDays float64) float64 {
|
||||
switch suffix {
|
||||
case "180d":
|
||||
return 50000
|
||||
case "365d":
|
||||
return 100000
|
||||
case "total":
|
||||
return 50000 * productPerformanceExcelTotalPeriodMultiplier(periodDays)
|
||||
default:
|
||||
return 25000
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelScorePeriodDays(suffix string, periodDays ...float64) float64 {
|
||||
if len(periodDays) > 0 && periodDays[0] > 0 {
|
||||
return periodDays[0]
|
||||
}
|
||||
switch suffix {
|
||||
case "90d":
|
||||
return 90
|
||||
case "180d":
|
||||
return 180
|
||||
case "365d":
|
||||
return 360
|
||||
case "total":
|
||||
start := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
now := time.Now()
|
||||
if now.Before(start) {
|
||||
return 0
|
||||
}
|
||||
return now.Sub(start).Hours()/24 + 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelTotalPeriodMultiplier(periodDays float64) float64 {
|
||||
if periodDays <= 0 {
|
||||
periodDays = 180
|
||||
}
|
||||
return math.Max(1, periodDays/180.0)
|
||||
}
|
||||
|
||||
func productPerformanceExcelMarginScore(margin float64) float64 {
|
||||
if margin < 0 {
|
||||
margin = 0
|
||||
}
|
||||
return productPerformanceExcelRatioScore(margin, 0.45)
|
||||
}
|
||||
|
||||
func productPerformanceExcelRatioScore(value, target float64) float64 {
|
||||
if target <= 0 || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
score := value * 100 / target
|
||||
if score > 100 {
|
||||
return 100
|
||||
}
|
||||
if score < 0 {
|
||||
return 0
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func productPerformanceExcelRoundScore(score float64) float64 {
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
return math.Round(score*10000) / 10000
|
||||
}
|
||||
|
||||
func productPerformanceExcelFloat(value any) float64 {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
case float32:
|
||||
return productPerformanceExcelFloat(float64(v))
|
||||
case int:
|
||||
return float64(v)
|
||||
case int64:
|
||||
return float64(v)
|
||||
case int32:
|
||||
return float64(v)
|
||||
case uint:
|
||||
return float64(v)
|
||||
case uint64:
|
||||
return float64(v)
|
||||
case uint32:
|
||||
return float64(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceExcelSortedSetLabel(values map[string]bool) string {
|
||||
out := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func productPerformanceExcelColumnWidth(header, kind string) float64 {
|
||||
if kind == "text" {
|
||||
if strings.Contains(header, "Piyasalar") || strings.Contains(header, "Öneri") {
|
||||
return 34
|
||||
}
|
||||
if len([]rune(header)) > 14 {
|
||||
return 24
|
||||
}
|
||||
return 16
|
||||
}
|
||||
if strings.Contains(header, "Marj") || strings.Contains(header, "Skor") {
|
||||
return 14
|
||||
}
|
||||
return 16
|
||||
}
|
||||
|
||||
func productPerformanceExcelMaxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -158,6 +158,26 @@ func GetProductionNoCostProductsHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if argeDB := db.GetArgeBaggiDB(); argeDB != nil && len(list) > 0 {
|
||||
workOrders := make([]string, 0, len(list))
|
||||
for i := range list {
|
||||
workOrders = append(workOrders, list[i].UrtSiparisNo)
|
||||
}
|
||||
argeCtx, cancelArge := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
totalsByWorkOrder, argeErr := queries.GetArgeBaggiDepotEntryTotals(argeCtx, argeDB, workOrders)
|
||||
cancelArge()
|
||||
if argeErr != nil {
|
||||
log.Printf("[ProductionNoCost] ArgeBAGGI enrichment error: %v", argeErr)
|
||||
} else {
|
||||
for i := range list {
|
||||
totals := totalsByWorkOrder[strings.TrimSpace(list[i].UrtSiparisNo)]
|
||||
list[i].CeketDepoGiris = totals.Ceket
|
||||
list[i].PantolonDepoGiris = totals.Pantolon
|
||||
list[i].YelekDepoGiris = totals.Yelek
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(list)
|
||||
}
|
||||
|
||||
@@ -191,6 +211,7 @@ func GetProductionHasCostProductsHandler(w http.ResponseWriter, r *http.Request)
|
||||
var item models.ProductionHasCostProductRow
|
||||
if err := rows.Scan(
|
||||
&item.UretimSekli,
|
||||
&item.UrtSiparisNo,
|
||||
&item.NOnMLNo,
|
||||
&item.UrunKodu,
|
||||
&item.UrunAdi,
|
||||
@@ -219,6 +240,26 @@ func GetProductionHasCostProductsHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if argeDB := db.GetArgeBaggiDB(); argeDB != nil && len(list) > 0 {
|
||||
workOrders := make([]string, 0, len(list))
|
||||
for i := range list {
|
||||
workOrders = append(workOrders, list[i].UrtSiparisNo)
|
||||
}
|
||||
argeCtx, cancelArge := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
totalsByWorkOrder, argeErr := queries.GetArgeBaggiDepotEntryTotals(argeCtx, argeDB, workOrders)
|
||||
cancelArge()
|
||||
if argeErr != nil {
|
||||
log.Printf("[ProductionHasCost] ArgeBAGGI enrichment error: %v", argeErr)
|
||||
} else {
|
||||
for i := range list {
|
||||
totals := totalsByWorkOrder[strings.TrimSpace(list[i].UrtSiparisNo)]
|
||||
list[i].CeketDepoGiris = totals.Ceket
|
||||
list[i].PantolonDepoGiris = totals.Pantolon
|
||||
list[i].YelekDepoGiris = totals.Yelek
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(list)
|
||||
}
|
||||
|
||||
@@ -1427,6 +1468,12 @@ func postProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
|
||||
totalUSD := 0.0
|
||||
totalEUR := 0.0
|
||||
for _, r := range req.Detail.Upserts {
|
||||
// The top header in the detail screen only includes rows marked
|
||||
// "Maliyete Dahil". Persist that exact business total to the master
|
||||
// record as well; excluded detail rows must not inflate the cost.
|
||||
if r.MaliyeteDahil != 1 {
|
||||
continue
|
||||
}
|
||||
qty := r.LMiktar
|
||||
if qty < 0 {
|
||||
qty = 0
|
||||
|
||||
+15
-1
@@ -1,8 +1,20 @@
|
||||
// quasar.config.js
|
||||
import { defineConfig } from '#q-app/wrappers'
|
||||
import { execSync } from 'node:child_process'
|
||||
|
||||
function readGitBuildInfo () {
|
||||
try {
|
||||
const commitCount = execSync('git rev-list --count HEAD', { encoding: 'utf8' }).trim()
|
||||
const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim()
|
||||
return { version: `v1.0.${commitCount}`, commit: commitHash }
|
||||
} catch {
|
||||
return { version: 'v1.0.0', commit: 'local' }
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(() => {
|
||||
const apiBaseUrl = (process.env.VITE_API_BASE_URL || '/api').trim()
|
||||
const gitBuildInfo = readGitBuildInfo()
|
||||
|
||||
return {
|
||||
|
||||
@@ -36,7 +48,9 @@ export default defineConfig(() => {
|
||||
build: {
|
||||
vueRouterMode: 'hash',
|
||||
env: {
|
||||
VITE_API_BASE_URL: apiBaseUrl
|
||||
VITE_API_BASE_URL: apiBaseUrl,
|
||||
APP_VERSION: gitBuildInfo.version,
|
||||
APP_COMMIT: gitBuildInfo.commit
|
||||
},
|
||||
|
||||
esbuildTarget: {
|
||||
|
||||
@@ -74,6 +74,10 @@
|
||||
</q-form>
|
||||
</q-card>
|
||||
|
||||
<div class="login-version" :title="`Commit: ${appCommit}`">
|
||||
Baggi BSS {{ appVersion }}
|
||||
</div>
|
||||
|
||||
<!-- 🔐 FORGOT PASSWORD -->
|
||||
<q-dialog v-model="forgotOpen" persistent>
|
||||
<q-card style="width:420px; max-width:90vw">
|
||||
@@ -129,6 +133,8 @@ import api from 'src/services/api'
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const $q = useQuasar()
|
||||
const appVersion = process.env.APP_VERSION || 'v1.0.0'
|
||||
const appCommit = process.env.APP_COMMIT || 'local'
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
@@ -266,6 +272,21 @@ async function sendResetMail () {
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-version {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 12px;
|
||||
z-index: 2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
color: rgba(45, 45, 45, 0.75);
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
user-select: text;
|
||||
}
|
||||
/* ===============================
|
||||
LOGIN INPUT TEXT COLOR (GOLD)
|
||||
=============================== */
|
||||
|
||||
@@ -1289,14 +1289,12 @@ function normalizeUploadsPath (storagePath) {
|
||||
|
||||
function resolveProductImageUrl (item) {
|
||||
if (!item || typeof item !== 'object') return ''
|
||||
const imageId = Number(item.id || item.ID || 0)
|
||||
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
|
||||
const contentUrl = toText(item.content_url || item.ContentURL)
|
||||
if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl
|
||||
const thumbUrl = toText(item.thumb_url || item.thumbUrl)
|
||||
if (thumbUrl) return thumbUrl
|
||||
const fullUrl = toText(item.full_url || item.fullUrl)
|
||||
if (fullUrl) return fullUrl
|
||||
const contentUrl = toText(item.content_url || item.ContentURL)
|
||||
if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl
|
||||
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage)
|
||||
if (uploadsPath) return uploadsPath
|
||||
const fileName = toText(item.file_name || item.FileName)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<q-page class="product-performance-page q-pa-sm">
|
||||
<q-inner-loading :showing="pageBusy">
|
||||
<q-spinner-gears size="52px" color="primary" />
|
||||
@@ -14,7 +14,8 @@
|
||||
@wheel.stop
|
||||
>
|
||||
<q-spinner-gears size="56px" color="primary" />
|
||||
<div class="page-busy-label">Yükleniyor...</div>
|
||||
<div class="page-busy-label">{{ loadingMessage }}</div>
|
||||
<div v-if="loadingElapsedLabel" class="page-busy-time">{{ loadingElapsedLabel }}</div>
|
||||
</div>
|
||||
</teleport>
|
||||
<div class="performance-top-bar row items-center justify-between q-col-gutter-xs q-mb-xs">
|
||||
@@ -34,7 +35,18 @@
|
||||
:disable="pageBusy"
|
||||
class="period-selector bg-white q-px-xs q-py-none"
|
||||
/>
|
||||
<q-btn
|
||||
dense
|
||||
outline
|
||||
color="primary"
|
||||
icon="view_column"
|
||||
:label="detailColumnsHidden ? 'Detay Kolonları Göster' : 'Detay Kolonları Gizle'"
|
||||
:disable="pageBusy"
|
||||
@click="detailColumnsHidden = !detailColumnsHidden"
|
||||
/>
|
||||
<q-btn-dropdown
|
||||
v-model="detailLevelMenuOpen"
|
||||
split
|
||||
dense
|
||||
outline
|
||||
color="primary"
|
||||
@@ -52,11 +64,12 @@
|
||||
/>
|
||||
<q-separator class="q-my-sm" />
|
||||
<div class="row items-center justify-end q-gutter-xs">
|
||||
<q-btn dense flat color="grey-7" label="Kapat" :disable="pageBusy" @click="collapseAllProductGroups" />
|
||||
<q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click="expandSelectedProductGroups" />
|
||||
<q-btn dense flat color="grey-7" label="Kapat" :disable="pageBusy" @click.stop="collapseAllProductGroups" />
|
||||
<q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click.stop="expandSelectedProductGroups" />
|
||||
</div>
|
||||
</div>
|
||||
</q-btn-dropdown>
|
||||
<q-btn dense outline color="positive" icon="grid_on" label="Excel" :loading="excelExportLoading" :disable="pageBusy || excelExportLoading" @click="exportProductPerformanceExcel" />
|
||||
<q-btn dense outline color="secondary" icon="refresh" label="Yenile" :loading="loading" :disable="pageBusy" @click="reload" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,18 +99,8 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'product_detail'" class="main-group-bar q-mb-xs">
|
||||
<button
|
||||
v-for="option in detailMainGroupOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:class="['main-group-button', { active: selectedDetailMainGroup === option.value }]"
|
||||
:disabled="pageBusy"
|
||||
@click="selectedDetailMainGroup = option.value"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<small>{{ formatNumber(option.stock_qty, 0) }} stok</small>
|
||||
</button>
|
||||
<div ref="topScrollbarRef" class="performance-top-scrollbar q-mb-xs">
|
||||
<div ref="topScrollbarInnerRef" class="performance-top-scrollbar-inner"></div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
@@ -105,13 +108,19 @@
|
||||
flat
|
||||
bordered
|
||||
row-key="row_key"
|
||||
class="performance-table sticky-dim-table sticky-dim-7 bg-white"
|
||||
:class="[
|
||||
'performance-table',
|
||||
'sticky-dim-table',
|
||||
detailColumnsHidden ? 'sticky-dim-4' : 'sticky-dim-7',
|
||||
'bg-white'
|
||||
]"
|
||||
:rows="filteredGeneralRows"
|
||||
:columns="generalColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }"
|
||||
:columns="visibleGeneralColumns"
|
||||
:loading="activeTableLoading"
|
||||
v-model:pagination="tablePagination.general"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
:virtual-scroll-item-size="productThumbVirtualItemSize"
|
||||
:virtual-scroll-slice-size="tableVirtualSliceSize"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -190,7 +199,7 @@
|
||||
v-if="getCachedProductImageUrl(props.row)"
|
||||
:src="getCachedProductImageUrl(props.row)"
|
||||
class="product-thumb"
|
||||
@error="markProductImageFailed(props.row)"
|
||||
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@@ -219,11 +228,12 @@
|
||||
class="performance-table sticky-dim-table sticky-dim-8 bg-white"
|
||||
:rows="displayOrderProductCustomerTableRows"
|
||||
:columns="orderProductCustomerColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 0, sortBy: 'order_usd', descending: true }"
|
||||
:loading="activeTableLoading"
|
||||
v-model:pagination="tablePagination.order_product_customers"
|
||||
:sort-method="sortProductGroupedTableRows"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
:virtual-scroll-item-size="productThumbVirtualItemSize"
|
||||
:virtual-scroll-slice-size="tableVirtualSliceSize"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -302,7 +312,7 @@
|
||||
v-if="getCachedProductImageUrl(props.row)"
|
||||
:src="getCachedProductImageUrl(props.row)"
|
||||
class="product-thumb"
|
||||
@error="markProductImageFailed(props.row)"
|
||||
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@@ -335,7 +345,7 @@
|
||||
<q-tr
|
||||
v-if="props.row.__group"
|
||||
:props="props"
|
||||
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
|
||||
:class="groupRowClasses(props.row)"
|
||||
>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
|
||||
<template v-if="col.name === 'image' && groupShowsImage(props.row)">
|
||||
@@ -344,7 +354,7 @@
|
||||
v-if="getCachedProductImageUrl(props.row)"
|
||||
:src="getCachedProductImageUrl(props.row)"
|
||||
class="product-thumb"
|
||||
@error="markProductImageFailed(props.row)"
|
||||
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@@ -365,7 +375,8 @@
|
||||
dense
|
||||
round
|
||||
size="sm"
|
||||
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
|
||||
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
|
||||
:disable="!groupCanExpand(props.row)"
|
||||
@click.stop="toggleGroup(props.row.key)"
|
||||
/>
|
||||
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
|
||||
@@ -381,7 +392,7 @@
|
||||
v-if="getCachedProductImageUrl(props.row)"
|
||||
:src="getCachedProductImageUrl(props.row)"
|
||||
class="product-thumb"
|
||||
@error="markProductImageFailed(props.row)"
|
||||
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@@ -409,11 +420,12 @@
|
||||
class="performance-table sticky-dim-table sticky-dim-10 bg-white"
|
||||
:rows="displayOrderMarketDetailTableRows"
|
||||
:columns="orderMarketDetailColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 0, sortBy: 'order_date', descending: true }"
|
||||
:loading="activeTableLoading"
|
||||
v-model:pagination="tablePagination.order_market_details"
|
||||
:sort-method="sortProductGroupedTableRows"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
:virtual-scroll-slice-size="tableVirtualSliceSize"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -506,7 +518,7 @@
|
||||
<q-tr
|
||||
v-if="props.row.__group"
|
||||
:props="props"
|
||||
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
|
||||
:class="groupRowClasses(props.row)"
|
||||
>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
|
||||
<div
|
||||
@@ -519,7 +531,8 @@
|
||||
dense
|
||||
round
|
||||
size="sm"
|
||||
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
|
||||
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
|
||||
:disable="!groupCanExpand(props.row)"
|
||||
@click.stop="toggleGroup(props.row.key)"
|
||||
/>
|
||||
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
|
||||
@@ -547,15 +560,17 @@
|
||||
'performance-table',
|
||||
'product-breakdown-table',
|
||||
'bg-white',
|
||||
{ 'compact-detail-columns': detailColumnsHidden },
|
||||
activeTab === 'product_detail' ? 'product-detail-table' : 'product-summary-table'
|
||||
]"
|
||||
:rows="displayProductKpiTableRows"
|
||||
:columns="activeProductColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="pagination"
|
||||
:loading="activeTableLoading"
|
||||
v-model:pagination="activeProductPagination"
|
||||
:sort-method="sortProductGroupedTableRows"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
:virtual-scroll-item-size="productThumbVirtualItemSize"
|
||||
:virtual-scroll-slice-size="tableVirtualSliceSize"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -632,7 +647,7 @@
|
||||
<q-tr
|
||||
v-if="props.row.__group"
|
||||
:props="props"
|
||||
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
|
||||
:class="groupRowClasses(props.row)"
|
||||
>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
|
||||
<template v-if="col.name === 'image' && groupShowsImage(props.row)">
|
||||
@@ -641,7 +656,7 @@
|
||||
v-if="getCachedProductImageUrl(props.row)"
|
||||
:src="getCachedProductImageUrl(props.row)"
|
||||
class="product-thumb"
|
||||
@error="markProductImageFailed(props.row)"
|
||||
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@@ -662,7 +677,8 @@
|
||||
dense
|
||||
round
|
||||
size="sm"
|
||||
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
|
||||
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
|
||||
:disable="!groupCanExpand(props.row)"
|
||||
@click.stop="toggleGroup(props.row.key)"
|
||||
/>
|
||||
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
|
||||
@@ -678,7 +694,7 @@
|
||||
v-if="getCachedProductImageUrl(props.row)"
|
||||
:src="getCachedProductImageUrl(props.row)"
|
||||
class="product-thumb"
|
||||
@error="markProductImageFailed(props.row)"
|
||||
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@@ -706,11 +722,12 @@
|
||||
class="performance-table sticky-dim-table sticky-dim-11 bg-white"
|
||||
:rows="displayIdleTableRows"
|
||||
:columns="idleColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 0 }"
|
||||
:loading="activeTableLoading"
|
||||
v-model:pagination="tablePagination.idle"
|
||||
:sort-method="sortProductGroupedTableRows"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
:virtual-scroll-item-size="productThumbVirtualItemSize"
|
||||
:virtual-scroll-slice-size="tableVirtualSliceSize"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -789,7 +806,7 @@
|
||||
v-if="getCachedProductImageUrl(props.row)"
|
||||
:src="getCachedProductImageUrl(props.row)"
|
||||
class="product-thumb"
|
||||
@error="markProductImageFailed(props.row)"
|
||||
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@@ -818,7 +835,7 @@
|
||||
<q-tr
|
||||
v-if="props.row.__group"
|
||||
:props="props"
|
||||
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
|
||||
:class="groupRowClasses(props.row)"
|
||||
>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
|
||||
<template v-if="col.name === 'image' && groupShowsImage(props.row)">
|
||||
@@ -827,7 +844,7 @@
|
||||
v-if="getCachedProductImageUrl(props.row)"
|
||||
:src="getCachedProductImageUrl(props.row)"
|
||||
class="product-thumb"
|
||||
@error="markProductImageFailed(props.row)"
|
||||
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@@ -848,7 +865,8 @@
|
||||
dense
|
||||
round
|
||||
size="sm"
|
||||
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
|
||||
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
|
||||
:disable="!groupCanExpand(props.row)"
|
||||
@click.stop="toggleGroup(props.row.key)"
|
||||
/>
|
||||
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
|
||||
@@ -864,7 +882,7 @@
|
||||
v-if="getCachedProductImageUrl(props.row)"
|
||||
:src="getCachedProductImageUrl(props.row)"
|
||||
class="product-thumb"
|
||||
@error="markProductImageFailed(props.row)"
|
||||
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@@ -892,10 +910,11 @@
|
||||
class="performance-table sticky-dim-table sticky-dim-9 bg-white"
|
||||
:rows="filteredMarketRows"
|
||||
:columns="marketColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 0 }"
|
||||
:loading="activeTableLoading"
|
||||
v-model:pagination="tablePagination.markets"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
:virtual-scroll-slice-size="tableVirtualSliceSize"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -998,11 +1017,12 @@
|
||||
]"
|
||||
:rows="displayActiveSalesBreakdownTableRows"
|
||||
:columns="visibleSalesBreakdownColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }"
|
||||
:loading="activeTableLoading"
|
||||
v-model:pagination="activeSalesBreakdownPagination"
|
||||
:sort-method="sortProductGroupedTableRows"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
:virtual-scroll-item-size="productThumbVirtualItemSize"
|
||||
:virtual-scroll-slice-size="tableVirtualSliceSize"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -1100,7 +1120,7 @@
|
||||
<q-tr
|
||||
v-if="props.row.__group"
|
||||
:props="props"
|
||||
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
|
||||
:class="groupRowClasses(props.row)"
|
||||
>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
|
||||
<div
|
||||
@@ -1113,7 +1133,8 @@
|
||||
dense
|
||||
round
|
||||
size="sm"
|
||||
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
|
||||
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
|
||||
:disable="!groupCanExpand(props.row)"
|
||||
@click.stop="toggleGroup(props.row.key)"
|
||||
/>
|
||||
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
|
||||
@@ -1156,10 +1177,11 @@
|
||||
class="performance-table sticky-dim-table sticky-dim-5 bg-white"
|
||||
:rows="filteredCustomerRows"
|
||||
:columns="customerColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 0 }"
|
||||
:loading="activeTableLoading"
|
||||
v-model:pagination="tablePagination.customers"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
:virtual-scroll-slice-size="tableVirtualSliceSize"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -1257,10 +1279,11 @@
|
||||
class="performance-table sticky-dim-table sticky-dim-3 bg-white"
|
||||
:rows="filteredCountryRows"
|
||||
:columns="countryColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 0 }"
|
||||
:loading="activeTableLoading"
|
||||
v-model:pagination="tablePagination.countries"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
:virtual-scroll-slice-size="tableVirtualSliceSize"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -1396,7 +1419,7 @@
|
||||
:name="idx"
|
||||
class="column no-wrap flex-center"
|
||||
>
|
||||
<q-img :src="url" fit="contain" class="product-image-large" />
|
||||
<q-img :src="url" fit="contain" class="product-image-large" @error="markDialogImageFailed(url)" />
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
<div v-else class="product-image-empty">
|
||||
@@ -1497,16 +1520,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import api from 'src/services/api'
|
||||
import api, { extractApiErrorDetail } from 'src/services/api'
|
||||
import { useProductPerformanceStore } from 'src/stores/productPerformanceStore'
|
||||
|
||||
const performanceStore = useProductPerformanceStore()
|
||||
|
||||
function formatNumber (value, fraction = 2) {
|
||||
const n = Number(value || 0)
|
||||
const digits = Math.max(0, Math.min(2, Number(fraction || 0)))
|
||||
const requested = Number(fraction)
|
||||
const digits = Number.isFinite(requested) ? Math.max(0, Math.min(4, requested)) : 2
|
||||
return n.toLocaleString('tr-TR', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits
|
||||
@@ -1522,7 +1546,9 @@ function formatPercent (value) {
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const excelExportLoading = ref(false)
|
||||
const rows = ref([])
|
||||
const detailMainGroupOptionRows = ref([])
|
||||
const generalRows = ref([])
|
||||
const orderAnalysisRows = ref([])
|
||||
const orderMarketRows = ref([])
|
||||
@@ -1532,6 +1558,9 @@ const orderMarketDetailRows = ref([])
|
||||
const orderAnalysisLoaded = ref(false)
|
||||
const orderProductCustomersLoaded = ref(false)
|
||||
const orderMarketDetailsLoaded = ref(false)
|
||||
const marketRowsLoaded = ref(false)
|
||||
const countryRowsLoaded = ref(false)
|
||||
const customerRowsLoaded = ref(false)
|
||||
const orderAnalysisMode = ref('product')
|
||||
const marketRows = ref([])
|
||||
const countryRows = ref([])
|
||||
@@ -1549,6 +1578,8 @@ const selectedPeriods = ref(['90', '180', '360', 'total'])
|
||||
const selectedDetailMainGroup = ref('')
|
||||
const imageUrlByKey = ref({})
|
||||
const imageListByKey = ref({})
|
||||
const imageFailedKeys = ref(new Set())
|
||||
const imageLoadingKeys = ref(new Set())
|
||||
const imageDialog = ref(false)
|
||||
const imageDialogUrls = ref([])
|
||||
const imageDialogTitle = ref('')
|
||||
@@ -1563,48 +1594,151 @@ const detailLoading = ref(false)
|
||||
const detailSalesRows = ref([])
|
||||
const detailStockSizes = ref([])
|
||||
const backendGroupedRows = ref({})
|
||||
const backendGroupedFilterOptions = ref({})
|
||||
const backendGroupedLoading = ref(false)
|
||||
const imagePrimeLoading = ref(false)
|
||||
const filterBusy = ref(false)
|
||||
const bulkExpandLoading = ref(false)
|
||||
const loadingStartedAt = ref(0)
|
||||
const loadingNow = ref(0)
|
||||
const loadingStage = ref('')
|
||||
const generalRowsLoaded = ref(false)
|
||||
const detailLevelMenuOpen = ref(false)
|
||||
const detailColumnsHidden = ref(false)
|
||||
const topScrollbarRef = ref(null)
|
||||
const topScrollbarInnerRef = ref(null)
|
||||
let backendGroupedTimer = null
|
||||
let imagePrimeActiveRequests = 0
|
||||
let activeTableMiddleEl = null
|
||||
let topScrollbarResizeObserver = null
|
||||
let topScrollbarSyncing = false
|
||||
let loadingTimer = null
|
||||
|
||||
const columnFilters = reactive({})
|
||||
const columnFilterSearch = reactive({})
|
||||
const expandedGroups = ref({})
|
||||
const lastManualExpandedGroupKey = ref('')
|
||||
const selectedExpandLevelKeysByTab = reactive({
|
||||
products: ['urun_ilk_grubu', 'askili_yan', 'kategori', 'urun_ana_grubu', 'urun_alt_grubu'],
|
||||
product_detail: ['urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'color_yaka'],
|
||||
sales_color_yaka_market_customer: ['color_yaka', 'urun_ilk_grubu', 'askili_yan'],
|
||||
idle: ['urun_ilk_grubu', 'askili_yan', 'kategori', 'urun_ana_grubu', 'urun_alt_grubu'],
|
||||
sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
|
||||
products: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
|
||||
product_detail: ['urun_alt_grubu'],
|
||||
sales_color_yaka_market_customer: ['urun_ana_grubu', 'color_yaka'],
|
||||
idle: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
|
||||
sales_product_country_segment_market_customer: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
|
||||
sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
|
||||
sales_country_segment_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
|
||||
order_product_customers: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
|
||||
sales_country_segment_market_customer_product: ['country', 'customer_segment', 'market_key'],
|
||||
order_product_customers: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
|
||||
order_market_details: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan']
|
||||
})
|
||||
const productKpiFetchLimit = 50000
|
||||
const reportFetchLimit = 50000
|
||||
const maxBulkExpandKeys = 1200
|
||||
const maxBulkExpandKeys = 1000
|
||||
const maxBackendExpandedKeys = 1000
|
||||
const tableVirtualSliceSize = 160
|
||||
const productThumbVirtualItemSize = 236
|
||||
const maxRenderedGroupedRows = 2500
|
||||
const maxAutoExpandLevelByTab = {
|
||||
products: 8,
|
||||
product_detail: 8,
|
||||
idle: 8,
|
||||
sales_color_yaka_market_customer: 8,
|
||||
sales_product_country_segment_market_customer: 8,
|
||||
sales_market_customer_product: 8,
|
||||
sales_country_segment_market_customer_product: 8,
|
||||
order_product_customers: 8,
|
||||
order_market_details: 8
|
||||
}
|
||||
const backendGroupedFilterFields = new Set([
|
||||
'kategori',
|
||||
'askili_yan',
|
||||
'urun_ilk_grubu',
|
||||
'urun_ana_grubu',
|
||||
'urun_alt_grubu',
|
||||
'product_code',
|
||||
'color_yaka',
|
||||
'market_key',
|
||||
'country',
|
||||
'customer_segment',
|
||||
'customer_code',
|
||||
'customer_name',
|
||||
'performance_bucket'
|
||||
])
|
||||
const productPerformanceExcelExportFilterFields = new Set([
|
||||
'kategori',
|
||||
'askili_yan',
|
||||
'urun_ilk_grubu',
|
||||
'urun_ana_grubu',
|
||||
'urun_alt_grubu',
|
||||
'product_code',
|
||||
'color_yaka',
|
||||
'color_code',
|
||||
'yaka_kodu',
|
||||
'market_key',
|
||||
'performance_bucket'
|
||||
])
|
||||
const performanceTabs = [
|
||||
{ name: 'products', icon: 'dashboard', label: 'Genel Özet KPI' },
|
||||
{ name: 'product_detail', icon: 'category', label: 'Detay KPI' },
|
||||
{ name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Renk/Yaka > Piyasa > Müşteri' },
|
||||
{ name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Ürün Ana Grubu > Renk/Yaka > Piyasa > Müşteri' },
|
||||
{ name: 'idle', icon: 'warning', label: 'Atıl Stok / Maliyet' },
|
||||
{ name: 'sales_product_country_segment_market_customer', icon: 'account_tree', label: 'Ürün > Ülke > Segment > Piyasa > Müşteri' },
|
||||
{ name: 'sales_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' },
|
||||
{ name: 'order_product_customers', icon: 'assignment_ind', label: 'Ürün > Piyasa > Müşteri Sipariş' },
|
||||
{ name: 'order_market_details', icon: 'receipt_long', label: 'Piyasa > Müşteri > Ürün Sipariş' }
|
||||
]
|
||||
const pageBusy = computed(() => loading.value || backendGroupedLoading.value || imagePrimeLoading.value || filterBusy.value)
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
rowsPerPage: 0,
|
||||
sortBy: 'performance_score_total',
|
||||
descending: true
|
||||
const pageBusy = computed(() => loading.value)
|
||||
const activeTableLoading = computed(() => loading.value || (backendGroupedSupportedTab(activeTab.value) && backendGroupedLoading.value))
|
||||
const loadingElapsedLabel = computed(() => {
|
||||
if (!loadingStartedAt.value || !loadingNow.value) return ''
|
||||
const seconds = Math.max(0, Math.floor((loadingNow.value - loadingStartedAt.value) / 1000))
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const rest = seconds % 60
|
||||
return minutes > 0 ? `${minutes}:${String(rest).padStart(2, '0')}` : `${seconds} sn`
|
||||
})
|
||||
const loadingMessage = computed(() => loadingStage.value || 'Rapor verileri yükleniyor...')
|
||||
|
||||
const tablePagination = reactive({
|
||||
general: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
|
||||
products: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
|
||||
product_detail: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
|
||||
order_product_customers: { page: 1, rowsPerPage: 0, sortBy: 'order_usd', descending: true },
|
||||
order_market_details: { page: 1, rowsPerPage: 0, sortBy: 'order_date', descending: true },
|
||||
idle: { page: 1, rowsPerPage: 0, sortBy: 'idle_cost_usd', descending: true },
|
||||
markets: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_90d', descending: true },
|
||||
countries: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_90d', descending: true },
|
||||
customers: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_365d', descending: true },
|
||||
sales_color_yaka_market_customer: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
|
||||
sales_product_country_segment_market_customer: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
|
||||
sales_market_customer_product: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
|
||||
sales_country_segment_market_customer_product: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }
|
||||
})
|
||||
|
||||
const activeProductPagination = computed({
|
||||
get: () => tablePagination[activeTab.value === 'product_detail' ? 'product_detail' : 'products'],
|
||||
set: value => {
|
||||
tablePagination[activeTab.value === 'product_detail' ? 'product_detail' : 'products'] = normalizeTablePagination(value)
|
||||
}
|
||||
})
|
||||
|
||||
const activeSalesBreakdownPagination = computed({
|
||||
get: () => tablePagination[activeTab.value] || tablePagination.sales_color_yaka_market_customer,
|
||||
set: value => {
|
||||
tablePagination[activeTab.value] = normalizeTablePagination(value)
|
||||
}
|
||||
})
|
||||
|
||||
const activeBackendGroupedSortSignature = computed(() => {
|
||||
if (!backendGroupedSupportedTab(activeTab.value)) return ''
|
||||
const state = tablePagination[activeTab.value] || {}
|
||||
return `${activeTab.value}|${state.sortBy || ''}|${state.descending !== false ? 1 : 0}`
|
||||
})
|
||||
|
||||
function normalizeTablePagination (value) {
|
||||
return {
|
||||
page: Number(value?.page || 1),
|
||||
rowsPerPage: 0,
|
||||
sortBy: value?.sortBy || '',
|
||||
descending: value?.descending === true
|
||||
}
|
||||
}
|
||||
|
||||
const bucketOptions = [
|
||||
{ label: 'Yıldız Ürün', value: 'YILDIZ_URUN' },
|
||||
@@ -1649,9 +1783,9 @@ const detailSalesColumns = [
|
||||
{ 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_qty', label: 'Adet', field: row => formatNumber(row.sales_qty, 2), 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 }
|
||||
{ name: 'avg_price_usd', label: 'Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd, 'USD'), align: 'right', sortable: true }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
@@ -1683,16 +1817,16 @@ const columns = [
|
||||
{ name: 'stock_days_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 1), align: 'right', sortable: true },
|
||||
{ name: 'stock_days_365d', label: '360G Stok Gün', field: row => formatNumber(row.stock_days_365d, 1), align: 'right', sortable: true },
|
||||
{ name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_90d', label: '90G Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_180d', label: '180G Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_365d', label: '360G Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_total', label: 'Genel Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), 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: 'avg_price_usd_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' },
|
||||
{ name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, '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: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_180d', label: '180G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_365d', label: '360G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_total', label: 'Genel Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' },
|
||||
{ name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' },
|
||||
{ name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' },
|
||||
{ name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right' },
|
||||
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd', label: '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 },
|
||||
@@ -1702,6 +1836,8 @@ const columns = [
|
||||
{ name: 'gross_profit_usd_90d', label: '90G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_90d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_usd_180d', label: '180G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_usd_total', label: 'Genel Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_cost_usd_total', label: 'Genel Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_margin_base_90d', label: '90G Taban Marj', field: row => formatPercent(row.gross_margin_base_90d), align: 'right', sortable: true },
|
||||
{ name: 'gross_margin_cost_90d', label: '90G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_90d), align: 'right', sortable: true },
|
||||
{ name: 'gross_margin_base_180d', label: '180G Taban Marj', field: row => formatPercent(row.gross_margin_base_180d), align: 'right', sortable: true },
|
||||
@@ -1714,7 +1850,6 @@ const columns = [
|
||||
{ name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 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_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
|
||||
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
|
||||
]
|
||||
@@ -1733,7 +1868,23 @@ const periodOrder = {
|
||||
total: 4
|
||||
}
|
||||
|
||||
const hiddenTableColumns = new Set(['item_description', 'performance_score'])
|
||||
const hiddenTableColumns = new Set([
|
||||
'item_description',
|
||||
'performance_score',
|
||||
'gross_profit_usd_total',
|
||||
'invoice_count_90d',
|
||||
'invoice_count_180d',
|
||||
'invoice_count_365d',
|
||||
'invoice_count_total',
|
||||
'sales_index_90d',
|
||||
'sales_index_180d',
|
||||
'sales_index_365d',
|
||||
'sales_index_total',
|
||||
'stock_days_90d',
|
||||
'stock_days_180d',
|
||||
'stock_days_365d',
|
||||
'stock_days_total'
|
||||
])
|
||||
const dimensionColumnNames = [
|
||||
'product_code',
|
||||
'color_yaka',
|
||||
@@ -1873,7 +2024,32 @@ const productColumns = computed(() => orderedMetricColumns(columns.filter(visibl
|
||||
const productDetailColumns = computed(() => orderedMetricColumns(columns
|
||||
.filter(col => !['urun_ilk_grubu', 'askili_yan', 'kategori'].includes(col.name))
|
||||
.filter(visiblePeriodColumn)))
|
||||
const activeProductColumns = computed(() => activeTab.value === 'product_detail' ? productDetailColumns.value : productColumns.value)
|
||||
const detailColumnNames = new Set([
|
||||
'item_description',
|
||||
'kategori',
|
||||
'askili_yan',
|
||||
'urun_ilk_grubu',
|
||||
'urun_ana_grubu',
|
||||
'urun_alt_grubu',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'country',
|
||||
'customer_segment',
|
||||
'customer_code',
|
||||
'customer_name',
|
||||
'first_sale_date',
|
||||
'last_sale_date',
|
||||
'last_ref_number'
|
||||
])
|
||||
|
||||
function applyDetailColumnVisibility (sourceColumns) {
|
||||
if (!detailColumnsHidden.value) return sourceColumns
|
||||
return sourceColumns.filter(col => !detailColumnNames.has(col.name))
|
||||
}
|
||||
|
||||
const visibleProductColumns = computed(() => applyDetailColumnVisibility(productColumns.value))
|
||||
const visibleProductDetailColumns = computed(() => applyDetailColumnVisibility(productDetailColumns.value))
|
||||
const activeProductColumns = computed(() => activeTab.value === 'product_detail' ? visibleProductDetailColumns.value : visibleProductColumns.value)
|
||||
|
||||
const generalColumns = [
|
||||
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
|
||||
@@ -1890,29 +2066,33 @@ const generalColumns = [
|
||||
{ name: 'period_start', label: 'Başlangıç', field: 'period_start', align: 'left', sortable: true },
|
||||
{ name: 'period_end', label: 'Bitiş', field: 'period_end', align: 'left', sortable: true },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_total', label: 'Toplam Satış', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_total', label: 'Toplam USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_total', label: 'Toplam Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_total', label: 'Toplam Ciro USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_daily_sales_total', label: 'Günlük Ort.', field: row => formatNumber(row.avg_daily_sales_total, 3), align: 'right', sortable: true },
|
||||
{ name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'unit_profit_base_total', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'unit_profit_cost_total', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_usd_total', label: 'Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_base_usd_total', label: 'Taban Toplam K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_cost_usd_total', label: 'Çıplak Toplam K/Z', field: row => formatMoney(row.gross_profit_cost_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_margin_base_total', label: 'Genel Taban Marj', field: row => formatPercent(row.gross_margin_base_total), align: 'right', sortable: true },
|
||||
{ name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true },
|
||||
{ name: 'market_count_total', label: 'Toplam Piyasa', field: row => formatNumber(row.market_count_total, 0), align: 'right', sortable: true },
|
||||
{ name: 'customer_count_total', label: 'Toplam Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
|
||||
{ name: 'invoice_count_total', label: 'Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_score', label: 'Genel Skor', field: row => formatNumber(row.performance_score, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_score_total', label: 'Genel Skor', field: row => formatNumber(row.performance_score_total ?? row.performance_score, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
|
||||
{ name: 'first_sale_date', label: 'İlk Satış', field: 'first_sale_date', align: 'left', sortable: true },
|
||||
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
|
||||
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
|
||||
]
|
||||
|
||||
const visibleGeneralColumns = computed(() => applyDetailColumnVisibility(generalColumns))
|
||||
|
||||
const orderAnalysisColumns = [
|
||||
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
|
||||
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
|
||||
@@ -1955,7 +2135,7 @@ const orderGroupColumns = [
|
||||
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
|
||||
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'base_cost_usd', label: 'Taban Maliyet USD', field: row => formatMoney(row.base_cost_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_amount_usd', label: 'Çıplak Maliyet USD', field: row => formatMoney(row.cost_amount_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
|
||||
@@ -1989,7 +2169,7 @@ const orderProductCustomerColumns = [
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
|
||||
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
|
||||
{ name: 'base_price_usd', label: 'Taban USD', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
@@ -2023,7 +2203,7 @@ const orderMarketDetailColumns = [
|
||||
{ name: 'due_date', label: 'Termin', field: 'due_date', align: 'left', sortable: true },
|
||||
{ name: 'order_qty', label: 'Adet', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'order_usd', label: 'USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
|
||||
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
|
||||
@@ -2052,7 +2232,7 @@ const idleColumns = [
|
||||
{ name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), 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_turnover_90d', label: '90G Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
|
||||
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
|
||||
]
|
||||
@@ -2083,7 +2263,7 @@ const countryColumns = [
|
||||
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_90d', label: '90G USD', field: row => formatMoney(row.sales_usd_90d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'customer_count_90d', label: 'Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
|
||||
@@ -2099,7 +2279,7 @@ const customerColumns = [
|
||||
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. Satış Fiyatı USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'base_price_usd_90d', label: '90G Taban', field: row => formatMoney(row.base_price_usd_90d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd_90d', label: '90G Çıplak', field: row => formatMoney(row.cost_price_usd_90d, 'USD'), align: 'right', sortable: true },
|
||||
@@ -2134,14 +2314,14 @@ const salesBreakdownColumns = [
|
||||
{ name: 'invoice_count_90d', label: '90G Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_90d', label: '90G Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'customer_count_180d', label: '180G Müşteri', field: row => formatNumber(row.customer_count_180d, 0), align: 'right', sortable: true },
|
||||
{ name: 'invoice_count_180d', label: '180G Fatura', field: row => formatNumber(row.invoice_count_180d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_180d', label: '180G Adet', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_180d', label: '180G USD', field: row => formatMoney(row.sales_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_180d', label: '180G Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_180d', label: '180G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
|
||||
{ name: 'base_price_usd_180d', label: '180G Taban', field: row => formatMoney(row.base_price_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd_180d', label: '180G Çıplak', field: row => formatMoney(row.cost_price_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_base_usd_180d', label: '180G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_180d, 'USD'), align: 'right', sortable: true },
|
||||
@@ -2151,8 +2331,8 @@ const salesBreakdownColumns = [
|
||||
{ name: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 2), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_365d', label: '360G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_365d', label: '360G Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_365d', label: '360G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
|
||||
{ name: 'base_price_usd_365d', label: '360G Taban', field: row => formatMoney(row.base_price_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd_365d', label: '360G Çıplak', field: row => formatMoney(row.cost_price_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_base_usd_365d', label: '360G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_365d, 'USD'), align: 'right', sortable: true },
|
||||
@@ -2164,8 +2344,8 @@ const salesBreakdownColumns = [
|
||||
{ name: 'invoice_count_total', label: 'Genel Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_total', label: 'Genel Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_total', label: 'Genel USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_total', label: 'Genel Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'stock_turnover_total', label: 'Genel Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
|
||||
{ name: 'base_price_usd_total', label: 'Genel Taban', field: row => formatMoney(row.base_price_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd_total', label: 'Genel Çıplak', field: row => formatMoney(row.cost_price_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
|
||||
@@ -2174,7 +2354,6 @@ const salesBreakdownColumns = [
|
||||
{ name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true },
|
||||
{ name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 2), align: 'right', sortable: true },
|
||||
{ name: 'sales_index_90d', label: 'Endeks', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
|
||||
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
|
||||
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
|
||||
@@ -2194,6 +2373,108 @@ const customerPeriodScoreColumns = [
|
||||
{ name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 2), align: 'right', sortable: true }
|
||||
]
|
||||
|
||||
const metricLabelOverrides = {
|
||||
stock_qty: 'Toplam Stok',
|
||||
sales_qty: 'Toplam Adet',
|
||||
sales_qty_90d: '90G Toplam Adet',
|
||||
sales_qty_180d: '180G Toplam Adet',
|
||||
sales_qty_365d: '360G Toplam Adet',
|
||||
sales_qty_total: 'Genel Toplam Adet',
|
||||
sales_usd: 'Toplam Ciro USD',
|
||||
sales_usd_90d: '90G Toplam Ciro USD',
|
||||
sales_usd_180d: '180G Toplam Ciro USD',
|
||||
sales_usd_365d: '360G Toplam Ciro USD',
|
||||
sales_usd_total: 'Genel Toplam Ciro USD',
|
||||
avg_price_usd: 'Ort. Satış Fiyatı USD',
|
||||
avg_price_usd_90d: '90G Ort. Satış Fiyatı USD',
|
||||
avg_price_usd_180d: '180G Ort. Satış Fiyatı USD',
|
||||
avg_price_usd_365d: '360G Ort. Satış Fiyatı USD',
|
||||
avg_price_usd_total: 'Genel Ort. Satış Fiyatı USD',
|
||||
base_price_usd: 'Taban Maliyet USD',
|
||||
cost_price_usd: 'Çıplak Maliyet USD',
|
||||
base_price_usd_90d: '90G Taban Maliyet USD',
|
||||
cost_price_usd_90d: '90G Çıplak Maliyet USD',
|
||||
base_price_usd_180d: '180G Taban Maliyet USD',
|
||||
cost_price_usd_180d: '180G Çıplak Maliyet USD',
|
||||
base_price_usd_365d: '360G Taban Maliyet USD',
|
||||
cost_price_usd_365d: '360G Çıplak Maliyet USD',
|
||||
base_price_usd_total: 'Genel Taban Maliyet USD',
|
||||
cost_price_usd_total: 'Genel Çıplak Maliyet USD',
|
||||
unit_profit_base_90d: '90G Birim Taban K/Z USD',
|
||||
unit_profit_cost_90d: '90G Birim Çıplak K/Z USD',
|
||||
unit_profit_base_180d: '180G Birim Taban K/Z USD',
|
||||
unit_profit_cost_180d: '180G Birim Çıplak K/Z USD',
|
||||
unit_profit_base_total: 'Genel Birim Taban K/Z USD',
|
||||
unit_profit_cost_total: 'Genel Birim Çıplak K/Z USD',
|
||||
gross_profit_usd_90d: '90G Toplam K/Z USD',
|
||||
gross_profit_usd_180d: '180G Toplam K/Z USD',
|
||||
gross_profit_usd_total: 'Genel Toplam K/Z USD',
|
||||
gross_profit_base_usd_90d: '90G Taban Toplam K/Z USD',
|
||||
gross_profit_cost_usd_90d: '90G Çıplak Toplam K/Z USD',
|
||||
gross_profit_base_usd_180d: '180G Taban Toplam K/Z USD',
|
||||
gross_profit_cost_usd_180d: '180G Çıplak Toplam K/Z USD',
|
||||
gross_profit_base_usd_365d: '360G Taban Toplam K/Z USD',
|
||||
gross_profit_cost_usd_365d: '360G Çıplak Toplam K/Z USD',
|
||||
gross_profit_base_usd_total: 'Genel Taban Toplam K/Z USD',
|
||||
gross_profit_cost_usd_total: 'Genel Çıplak Toplam K/Z USD',
|
||||
gross_margin_base_90d: '90G Taban Marj',
|
||||
gross_margin_cost_90d: '90G Çıplak Marj',
|
||||
gross_margin_base_180d: '180G Taban Marj',
|
||||
gross_margin_cost_180d: '180G Çıplak Marj',
|
||||
gross_margin_base_365d: '360G Taban Marj',
|
||||
gross_margin_cost_365d: '360G Çıplak Marj',
|
||||
gross_margin_base_total: 'Genel Taban Marj',
|
||||
gross_margin_cost_total: 'Genel Çıplak Marj',
|
||||
performance_score_90d: '90G Ürün Skor',
|
||||
performance_score_180d: '180G Ürün Skor',
|
||||
performance_score_365d: '360G Ürün Skor',
|
||||
performance_score_total: 'Genel Ürün Skor',
|
||||
customer_score_90d: '90G Müşteri Skor',
|
||||
customer_score_180d: '180G Müşteri Skor',
|
||||
customer_score_365d: '360G Müşteri Skor',
|
||||
customer_score_total: 'Genel Müşteri Skor',
|
||||
market_count_90d: '90G Tekil Piyasa',
|
||||
customer_count_90d: '90G Tekil Müşteri',
|
||||
market_count_total: 'Genel Tekil Piyasa',
|
||||
customer_count_total: 'Genel Tekil Müşteri',
|
||||
stock_turnover_90d: '90G Yıllık Stok Devir',
|
||||
stock_turnover_180d: '180G Yıllık Stok Devir',
|
||||
stock_turnover_365d: '360G Yıllık Stok Devir',
|
||||
stock_turnover_total: 'Genel Yıllık Stok Devir',
|
||||
sales_index_90d: '90G Endeks',
|
||||
sales_index_total: 'Genel Endeks',
|
||||
stock_days_90d: '90G Stok Gün',
|
||||
stock_days_180d: '180G Stok Gün',
|
||||
stock_days_365d: '360G Stok Gün',
|
||||
stock_days_total: 'Genel Stok Gün',
|
||||
avg_daily_sales_total: 'Genel Günlük Ort. Adet'
|
||||
}
|
||||
|
||||
function applyMetricLabelOverrides (...columnGroups) {
|
||||
for (const group of columnGroups) {
|
||||
for (const col of group || []) {
|
||||
if (metricLabelOverrides[col?.name]) col.label = metricLabelOverrides[col.name]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyMetricLabelOverrides(
|
||||
detailSalesColumns,
|
||||
columns,
|
||||
generalColumns,
|
||||
orderAnalysisColumns,
|
||||
orderGroupColumns,
|
||||
orderProductCustomerColumns,
|
||||
orderMarketDetailColumns,
|
||||
idleColumns,
|
||||
marketColumns,
|
||||
countryColumns,
|
||||
customerColumns,
|
||||
salesBreakdownColumns,
|
||||
productPeriodScoreColumns,
|
||||
customerPeriodScoreColumns
|
||||
)
|
||||
|
||||
function ensureColumns (targetColumns, columnsToEnsure) {
|
||||
const existing = new Set(targetColumns.map(col => col.name))
|
||||
for (const col of columnsToEnsure) {
|
||||
@@ -2257,7 +2538,6 @@ function ensureColorYakaColumn (targetColumns) {
|
||||
].forEach(target => ensureColumns(target, customerPeriodScoreColumns))
|
||||
|
||||
;[
|
||||
generalColumns,
|
||||
orderAnalysisColumns,
|
||||
orderGroupColumns,
|
||||
orderProductCustomerColumns,
|
||||
@@ -2383,13 +2663,16 @@ const visibleOrderAnalysisColumns = computed(() => {
|
||||
function performanceCardsForRow (row) {
|
||||
const source = row || {}
|
||||
return [
|
||||
{ key: 'score', label: 'Skor', value: formatNumber(source.performance_score, 2) },
|
||||
{ key: 'score90', label: '90G Skor', value: formatNumber(source.performance_score_90d ?? source.performance_score, 2) },
|
||||
{ key: 'score180', label: '180G Skor', value: formatNumber(source.performance_score_180d, 2) },
|
||||
{ key: 'score360', label: '360G Skor', value: formatNumber(source.performance_score_365d, 2) },
|
||||
{ key: 'scoreTotal', label: 'Genel Skor', value: formatNumber(source.performance_score_total ?? source.performance_score, 2) },
|
||||
{ key: 'stock', label: 'Stok', value: formatNumber(source.stock_qty, 0) },
|
||||
{ key: 'sales90', label: '90G Satış', value: formatNumber(source.sales_qty_90d, 0) },
|
||||
{ key: 'sales180', label: '180G Satış', value: formatNumber(source.sales_qty_180d, 0) },
|
||||
{ key: 'markets', label: '90G Piyasa', value: formatNumber(source.market_count_90d, 0) },
|
||||
{ key: 'customers', label: '90G Müşteri', value: formatNumber(source.customer_count_90d, 0) },
|
||||
{ key: 'avg', label: 'Ort. USD Satış', value: formatMoney(source.avg_price_usd_90d, 'USD') },
|
||||
{ key: 'avg', label: 'Ort. Satış Fiyatı', value: formatMoney(source.avg_price_usd_90d, 'USD') },
|
||||
{ key: 'base', label: 'Taban Maliyet', value: formatMoney(source.base_price_usd, 'USD') },
|
||||
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(source.cost_price_usd, 'USD') },
|
||||
{ key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(source.unit_profit_base_90d, 'USD') },
|
||||
@@ -2431,33 +2714,26 @@ const groupLevels = [
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' },
|
||||
{ key: 'color_yaka', label: 'Renk/Yaka' },
|
||||
{ key: 'market_key', label: 'Piyasa' }
|
||||
]
|
||||
|
||||
const tabGroupLevels = {
|
||||
products: groupLevels,
|
||||
product_detail: [
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'color_yaka', label: 'Renk/Yaka' },
|
||||
{ key: 'market_key', label: 'Piyasa' }
|
||||
],
|
||||
sales_color_yaka_market_customer: [
|
||||
{ key: 'color_yaka', label: 'Renk/Yaka' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'kategori', label: 'Kategori' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'country', label: 'Ülke' },
|
||||
{ key: 'color_yaka', label: 'Renk/Yaka' },
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_segment', label: 'Segment' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' }
|
||||
{ key: 'customer_name', label: 'Müşteri' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' }
|
||||
],
|
||||
idle: [
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
|
||||
@@ -2466,21 +2742,21 @@ const tabGroupLevels = {
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
{ key: 'color_yaka', label: 'Renk/Yaka' }
|
||||
],
|
||||
sales_product_country_segment_market_customer: [
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'kategori', label: 'Kategori' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
{ key: 'color_yaka', label: 'Renk/Yaka' },
|
||||
{ key: 'country', label: 'Ülke' },
|
||||
{ key: 'customer_segment', label: 'Segment' },
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' }
|
||||
],
|
||||
sales_market_customer_product: [
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
@@ -2492,10 +2768,11 @@ const tabGroupLevels = {
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
{ key: 'color_yaka', label: 'Renk/Yaka' }
|
||||
],
|
||||
sales_country_segment_market_customer_product: [
|
||||
{ key: 'country', label: 'Ülke' },
|
||||
{ key: 'customer_segment', label: 'Segment' },
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' },
|
||||
@@ -2505,21 +2782,19 @@ const tabGroupLevels = {
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
{ key: 'color_yaka', label: 'Renk/Yaka' }
|
||||
],
|
||||
order_product_customers: [
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' },
|
||||
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
|
||||
{ key: 'askili_yan', label: 'Askılı/Yan' },
|
||||
{ key: 'kategori', label: 'Kategori' },
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
{ key: 'color_yaka', label: 'Renk/Yaka' },
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
{ key: 'customer_code', label: 'Müşteri Kodu' },
|
||||
{ key: 'customer_name', label: 'Müşteri' }
|
||||
],
|
||||
order_market_details: [
|
||||
{ key: 'market_key', label: 'Piyasa' },
|
||||
@@ -2531,8 +2806,7 @@ const tabGroupLevels = {
|
||||
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
|
||||
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
|
||||
{ key: 'product_code', label: 'Ürün' },
|
||||
{ key: 'color_code', label: 'Renk' },
|
||||
{ key: 'yaka_kodu', label: 'Yaka' }
|
||||
{ key: 'color_yaka', label: 'Renk/Yaka' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2558,27 +2832,28 @@ const selectedExpandThroughLevel = computed(() => {
|
||||
return indexes.length ? Math.max(...indexes) : -1
|
||||
})
|
||||
|
||||
function maxAutoExpandThroughLevelForTab (tabKey = activeTab.value) {
|
||||
return Number.isFinite(maxAutoExpandLevelByTab[tabKey]) ? maxAutoExpandLevelByTab[tabKey] : 8
|
||||
}
|
||||
|
||||
function maxBackendExpandedKeysForTab (tabKey = activeTab.value) {
|
||||
if (tabKey === 'product_detail') return 1000
|
||||
if (tabKey === 'products' || tabKey === 'idle') return 1000
|
||||
if (salesBreakdownTabKeys.includes(tabKey)) return 1000
|
||||
return 1000
|
||||
}
|
||||
|
||||
const autoExpandThroughLevel = computed(() => {
|
||||
const selectedLevel = selectedExpandThroughLevel.value
|
||||
const lastGroupLevel = Math.max(0, activeGroupLevels.value.length - 2)
|
||||
return selectedLevel >= 0 ? Math.min(selectedLevel, lastGroupLevel) : Math.min(2, lastGroupLevel)
|
||||
const requestedLevel = selectedLevel >= 0 ? selectedLevel : 2
|
||||
return Math.min(requestedLevel, lastGroupLevel, maxAutoExpandThroughLevelForTab())
|
||||
})
|
||||
|
||||
const tableFilterSourceMap = computed(() => ({
|
||||
products: { rows: rows.value, columns: productColumns.value },
|
||||
product_detail: { rows: detailProductRows.value, columns: productDetailColumns.value },
|
||||
general: { rows: generalRows.value, columns: generalColumns },
|
||||
order_product_customers: { rows: orderProductCustomerRows.value, columns: orderProductCustomerColumns },
|
||||
order_market_details: { rows: orderMarketDetailRows.value, columns: orderMarketDetailColumns },
|
||||
idle: { rows: idleRows.value, columns: idleColumns },
|
||||
markets: { rows: marketRows.value, columns: marketColumns },
|
||||
countries: { rows: countryRows.value, columns: countryColumns },
|
||||
customers: { rows: customerRows.value, columns: customerColumns },
|
||||
sales_color_yaka_market_customer: { rows: salesBreakdownRows.sales_color_yaka_market_customer, columns: visibleSalesBreakdownColumns.value },
|
||||
sales_product_country_segment_market_customer: { rows: salesBreakdownRows.sales_product_country_segment_market_customer, columns: visibleSalesBreakdownColumns.value },
|
||||
sales_market_customer_product: { rows: salesBreakdownRows.sales_market_customer_product, columns: visibleSalesBreakdownColumns.value },
|
||||
sales_country_segment_market_customer_product: { rows: salesBreakdownRows.sales_country_segment_market_customer_product, columns: visibleSalesBreakdownColumns.value }
|
||||
}))
|
||||
const fullExpandThroughLevel = computed(() => {
|
||||
const lastGroupLevel = Math.max(0, activeGroupLevels.value.length - 2)
|
||||
return Math.min(lastGroupLevel, maxAutoExpandThroughLevelForTab())
|
||||
})
|
||||
|
||||
const filteredProductRows = computed(() => filterRowsForTable('products', rows.value, productColumns.value))
|
||||
const isProductKpiTab = computed(() => activeTab.value === 'products' || activeTab.value === 'product_detail')
|
||||
@@ -2609,39 +2884,55 @@ const activeGroupSourceRows = computed(() => {
|
||||
|
||||
const columnFilterOptionMap = computed(() => {
|
||||
const out = {}
|
||||
for (const [tableKey, source] of Object.entries(tableFilterSourceMap.value)) {
|
||||
out[tableKey] = {}
|
||||
for (const col of source.columns) {
|
||||
if (!isColumnFilterable(col.name)) continue
|
||||
const seen = new Map()
|
||||
for (const row of source.rows) {
|
||||
const value = columnFilterValue(row, col)
|
||||
if (!value) continue
|
||||
if (!seen.has(value)) {
|
||||
seen.set(value, {
|
||||
label: columnFilterLabel(row, col),
|
||||
value
|
||||
})
|
||||
}
|
||||
}
|
||||
out[tableKey][col.name] = Array.from(seen.values())
|
||||
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'tr', { numeric: true }))
|
||||
const tableKey = isProductKpiTab.value ? activeProductTableKey.value : activeTab.value
|
||||
const source = tableFilterSource(tableKey)
|
||||
if (!source) return out
|
||||
out[tableKey] = {}
|
||||
const preparedOptionEntry = backendGroupedSupportedTab(tableKey)
|
||||
? backendGroupedFilterOptions.value[tableKey]
|
||||
: null
|
||||
const preparedOptions = preparedOptionEntry?._key === backendGroupedFilterOptionsKey(tableKey)
|
||||
? preparedOptionEntry.options || {}
|
||||
: {}
|
||||
for (const col of source.columns) {
|
||||
if (!isColumnFilterable(col.name)) continue
|
||||
if (backendGroupedSupportedTab(tableKey) && backendGroupedFilterFields.has(col.name)) {
|
||||
out[tableKey][col.name] = preparedOptions[col.name] || []
|
||||
continue
|
||||
}
|
||||
if (backendGroupedSupportedTab(tableKey) && !backendGroupedFilterFields.has(col.name)) {
|
||||
out[tableKey][col.name] = []
|
||||
continue
|
||||
}
|
||||
const seen = new Map()
|
||||
for (const row of source.rows) {
|
||||
const value = columnFilterValue(row, col)
|
||||
if (!value) continue
|
||||
if (!seen.has(value)) {
|
||||
seen.set(value, {
|
||||
label: columnFilterLabel(row, col),
|
||||
value
|
||||
})
|
||||
}
|
||||
}
|
||||
out[tableKey][col.name] = Array.from(seen.values())
|
||||
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'tr', { numeric: true }))
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const productTableRows = computed(() => {
|
||||
if (shouldUseBackendGroupedRows('products')) return []
|
||||
const out = []
|
||||
appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels)
|
||||
appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels, 'products')
|
||||
return out
|
||||
})
|
||||
const detailProductTableRows = computed(() => buildGroupedTableRows('product_detail', filteredDetailProductRows.value))
|
||||
const detailProductTableRows = computed(() => shouldUseBackendGroupedRows('product_detail') ? [] : buildGroupedTableRows('product_detail', filteredDetailProductRows.value))
|
||||
|
||||
const idleTableRows = computed(() => buildGroupedTableRows('idle', filteredIdleRows.value))
|
||||
const orderProductCustomerTableRows = computed(() => buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value))
|
||||
const orderMarketDetailTableRows = computed(() => buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value))
|
||||
const activeSalesBreakdownTableRows = computed(() => buildGroupedTableRows(activeTab.value, filteredActiveSalesBreakdownRows.value))
|
||||
const idleTableRows = computed(() => shouldUseBackendGroupedRows('idle') ? [] : buildGroupedTableRows('idle', filteredIdleRows.value))
|
||||
const orderProductCustomerTableRows = computed(() => shouldUseBackendGroupedRows('order_product_customers') ? [] : buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value))
|
||||
const orderMarketDetailTableRows = computed(() => shouldUseBackendGroupedRows('order_market_details') ? [] : buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value))
|
||||
const activeSalesBreakdownTableRows = computed(() => shouldUseBackendGroupedRows(activeTab.value) ? [] : buildGroupedTableRows(activeTab.value, filteredActiveSalesBreakdownRows.value))
|
||||
const displayProductTableRows = computed(() => backendRowsForTab('products', productTableRows.value))
|
||||
const displayDetailProductTableRows = computed(() => backendRowsForTab('product_detail', detailProductTableRows.value))
|
||||
const displayProductKpiTableRows = computed(() => activeTab.value === 'product_detail' ? displayDetailProductTableRows.value : displayProductTableRows.value)
|
||||
@@ -2649,21 +2940,68 @@ const displayIdleTableRows = computed(() => backendRowsForTab('idle', idleTableR
|
||||
const displayOrderProductCustomerTableRows = computed(() => backendRowsForTab('order_product_customers', orderProductCustomerTableRows.value))
|
||||
const displayOrderMarketDetailTableRows = computed(() => backendRowsForTab('order_market_details', orderMarketDetailTableRows.value))
|
||||
const displayActiveSalesBreakdownTableRows = computed(() => backendRowsForTab(activeTab.value, activeSalesBreakdownTableRows.value))
|
||||
const activeDisplayedTableRows = computed(() => {
|
||||
if (isProductKpiTab.value) return displayProductKpiTableRows.value
|
||||
if (activeTab.value === 'idle') return displayIdleTableRows.value
|
||||
if (activeTab.value === 'order_product_customers') return displayOrderProductCustomerTableRows.value
|
||||
if (activeTab.value === 'order_market_details') return displayOrderMarketDetailTableRows.value
|
||||
if (salesBreakdownTabKeys.includes(activeTab.value)) return displayActiveSalesBreakdownTableRows.value
|
||||
if (activeTab.value === 'general') return filteredGeneralRows.value
|
||||
if (activeTab.value === 'markets') return filteredMarketRows.value
|
||||
if (activeTab.value === 'countries') return filteredCountryRows.value
|
||||
if (activeTab.value === 'customers') return filteredCustomerRows.value
|
||||
return []
|
||||
})
|
||||
|
||||
const detailMainGroupOptions = computed(() => {
|
||||
if (detailMainGroupOptionRows.value.length) {
|
||||
return [...detailMainGroupOptionRows.value]
|
||||
.sort((a, b) => Number(b.sales_qty_90d || 0) - Number(a.sales_qty_90d || 0) || a.label.localeCompare(b.label, 'tr'))
|
||||
}
|
||||
const grouped = new Map()
|
||||
for (const row of rows.value) {
|
||||
const value = productMainGroupValue(row)
|
||||
if (!value) continue
|
||||
const current = grouped.get(value) || { value, label: value, stock_qty: 0, sales_qty_90d: 0 }
|
||||
current.stock_qty += Number(row?.stock_qty || 0)
|
||||
const current = grouped.get(value) || { value, label: value, stock_qty: 0, sales_qty_90d: 0, _stockSeen: new Set() }
|
||||
const variantKey = productVariantMetricKey(row)
|
||||
if (variantKey && !current._stockSeen.has(variantKey)) {
|
||||
current._stockSeen.add(variantKey)
|
||||
current.stock_qty += Number(row?.stock_qty || 0)
|
||||
} else if (!variantKey) {
|
||||
current.stock_qty += Number(row?.stock_qty || 0)
|
||||
}
|
||||
current.sales_qty_90d += Number(row?.sales_qty_90d || 0)
|
||||
grouped.set(value, current)
|
||||
}
|
||||
return Array.from(grouped.values())
|
||||
.map(({ _stockSeen, ...option }) => option)
|
||||
.sort((a, b) => Number(b.sales_qty_90d || 0) - Number(a.sales_qty_90d || 0) || a.label.localeCompare(b.label, 'tr'))
|
||||
})
|
||||
|
||||
function filterRowsBySelectedDetailMainGroup (sourceRows) {
|
||||
const selected = String(selectedDetailMainGroup.value || '').trim()
|
||||
const rows = Array.isArray(sourceRows) ? sourceRows : []
|
||||
if (!selected) return []
|
||||
return rows.filter(row => {
|
||||
const value = productMainGroupValue(row)
|
||||
if (value === selected) return true
|
||||
return Boolean(row?.__group && row.group_field !== 'urun_ana_grubu' && !value)
|
||||
})
|
||||
}
|
||||
|
||||
function ensureDetailMainGroupSelection () {
|
||||
const options = detailMainGroupOptions.value
|
||||
if (!options.length) {
|
||||
selectedDetailMainGroup.value = ''
|
||||
return false
|
||||
}
|
||||
const preferred = options.find(option => String(option.value || '').toLocaleUpperCase('tr-TR') === 'TAKIM ELBISE')
|
||||
if (!selectedDetailMainGroup.value || !options.some(option => option.value === selectedDetailMainGroup.value)) {
|
||||
selectedDetailMainGroup.value = (preferred || options[0]).value
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const productGroupKeys = computed(() => {
|
||||
const keys = []
|
||||
collectGroupKeys(keys, filteredProductRows.value, 0, ['tab:products'], groupLevels)
|
||||
@@ -2677,17 +3015,16 @@ const productAutoExpandKeys = computed(() => {
|
||||
})
|
||||
|
||||
const allProductGroupsExpanded = computed(() => {
|
||||
const keys = productAutoExpandKeys.value
|
||||
return keys.length > 0 && keys.every(key => expandedGroups.value[key] === true)
|
||||
return selectedExpandThroughLevel.value >= fullExpandThroughLevel.value
|
||||
})
|
||||
|
||||
function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels) {
|
||||
appendGroupRowsAt(out, sourceRows, level, 0, parentKeys, levels)
|
||||
function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels, tableKey = activeTab.value) {
|
||||
appendGroupRowsAt(out, sourceRows, level, 0, parentKeys, levels, tableKey)
|
||||
}
|
||||
|
||||
function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, levels = groupLevels) {
|
||||
function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, levels = groupLevels, tableKey = activeTab.value) {
|
||||
if (out.length >= maxRenderedGroupedRows) return
|
||||
if (level >= levels.length) {
|
||||
out.push(...sortProductLeafRows(sourceRows))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2700,16 +3037,17 @@ function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, lev
|
||||
}
|
||||
|
||||
Array.from(grouped.entries())
|
||||
.sort((a, b) => a[0].localeCompare(b[0], 'tr'))
|
||||
.sort((a, b) => compareGroupedBucketsForTable(tableKey, a, b, groupDef))
|
||||
.forEach(([value, groupRows]) => {
|
||||
if (out.length >= maxRenderedGroupedRows) return
|
||||
if (shouldSkipGroupValue(groupDef.key, value)) {
|
||||
appendGroupRowsAt(out, groupRows, level + 1, visualLevel, parentKeys, levels)
|
||||
appendGroupRowsAt(out, groupRows, level + 1, visualLevel, parentKeys, levels, tableKey)
|
||||
return
|
||||
}
|
||||
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
|
||||
out.push(makeGroupRow(key, visualLevel, groupDef, value, groupRows))
|
||||
if (isGroupExpanded(key)) {
|
||||
appendGroupRowsAt(out, groupRows, level + 1, visualLevel + 1, [...parentKeys, `${groupDef.key}:${value}`], levels)
|
||||
appendGroupRowsAt(out, groupRows, level + 1, visualLevel + 1, [...parentKeys, `${groupDef.key}:${value}`], levels, tableKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2717,14 +3055,160 @@ function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, lev
|
||||
function buildGroupedTableRows (tableKey, sourceRows) {
|
||||
const levels = tabGroupLevels[tableKey] || groupLevels
|
||||
const out = []
|
||||
appendGroupRows(out, sourceRows, 0, [`tab:${tableKey}`], levels)
|
||||
appendGroupRows(out, sourceRows, 0, [`tab:${tableKey}`], levels, tableKey)
|
||||
return out
|
||||
}
|
||||
|
||||
function backendRowsForTab (tabKey, fallbackRows) {
|
||||
if (hasActiveTableFilters(tabKey)) return fallbackRows
|
||||
const rows = backendGroupedRows.value[tabKey]
|
||||
return Array.isArray(rows) && rows.length ? rows : fallbackRows
|
||||
if (shouldUseBackendGroupedRows(tabKey)) {
|
||||
const rows = backendGroupedRows.value[tabKey]
|
||||
return Array.isArray(rows) ? rows : []
|
||||
}
|
||||
return tabKey === 'product_detail'
|
||||
? filterRowsBySelectedDetailMainGroup(fallbackRows)
|
||||
: fallbackRows
|
||||
}
|
||||
|
||||
function filterSourceRowsForTab (tabKey, fallbackRows) {
|
||||
return fallbackRows
|
||||
}
|
||||
|
||||
function backendGroupedFilterOptionParams (tabKey) {
|
||||
const columns = filterColumnsForTableKey(tabKey)
|
||||
const fields = columns
|
||||
.map(col => col?.name || '')
|
||||
.filter(name => backendGroupedFilterFields.has(name))
|
||||
const params = {
|
||||
mode: tabKey,
|
||||
groupLevels: (tabGroupLevels[tabKey] || groupLevels).map(level => level.key),
|
||||
fields,
|
||||
limit: 50000
|
||||
}
|
||||
if (tabKey === 'product_detail') params.urunAnaGrubu = selectedDetailMainGroup.value
|
||||
return params
|
||||
}
|
||||
|
||||
function backendGroupedFilterOptionsKey (tabKey, params = backendGroupedFilterOptionParams(tabKey)) {
|
||||
return JSON.stringify({
|
||||
tabKey,
|
||||
mainGroup: params.urunAnaGrubu || '',
|
||||
groupLevels: params.groupLevels || [],
|
||||
fields: params.fields || []
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureBackendGroupedFilterOptions (tabKey = activeTab.value, force = false) {
|
||||
if (!backendGroupedSupportedTab(tabKey)) return
|
||||
if (tabKey === 'product_detail' && !selectedDetailMainGroup.value) return
|
||||
const params = backendGroupedFilterOptionParams(tabKey)
|
||||
if (!params.fields.length) return
|
||||
const key = backendGroupedFilterOptionsKey(tabKey, params)
|
||||
if (!force && backendGroupedFilterOptions.value[tabKey]?._key === key) return
|
||||
try {
|
||||
const data = await performanceStore.fetchGroupedFilterOptions(params, { force })
|
||||
if (backendGroupedFilterOptionsKey(tabKey) !== key) return
|
||||
backendGroupedFilterOptions.value = {
|
||||
...backendGroupedFilterOptions.value,
|
||||
[tabKey]: {
|
||||
_key: key,
|
||||
options: normalizeBackendGroupedFilterOptions(data)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('product performance grouped filter options failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBackendGroupedFilterOptions (data) {
|
||||
const out = {}
|
||||
const source = data && typeof data === 'object' ? data : {}
|
||||
for (const [field, values] of Object.entries(source)) {
|
||||
if (!Array.isArray(values)) continue
|
||||
const seen = new Map()
|
||||
for (const raw of values) {
|
||||
const value = normalizeBackendFilterValue(field, raw)
|
||||
if (!value || seen.has(value)) continue
|
||||
seen.set(value, {
|
||||
value,
|
||||
label: backendFilterOptionLabel(field, value)
|
||||
})
|
||||
}
|
||||
out[field] = Array.from(seen.values())
|
||||
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'tr', { numeric: true }))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeBackendFilterValue (name, value) {
|
||||
const text = String(value || '').trim()
|
||||
if (name === 'performance_bucket') {
|
||||
return bucketOptions.find(option => option.value === text || option.label === text)?.value || text
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function backendFilterOptionLabel (name, value) {
|
||||
if (name === 'performance_bucket') return bucketLabel(value)
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function backendGroupedFilterState (tabKey) {
|
||||
const out = {}
|
||||
const columns = filterColumnsForTableKey(tabKey)
|
||||
for (const col of columns) {
|
||||
const name = col?.name || ''
|
||||
if (!isColumnFilterable(name)) continue
|
||||
const selected = selectedColumnFilters(tabKey, name)
|
||||
if (!selected.length) continue
|
||||
if (!backendGroupedFilterFields.has(name)) {
|
||||
continue
|
||||
}
|
||||
out[name] = selected.map(value => normalizeBackendFilterValue(name, value)).filter(Boolean)
|
||||
}
|
||||
return { filters: out, unsupported: false }
|
||||
}
|
||||
|
||||
function shouldUseBackendGroupedRows (tabKey) {
|
||||
return backendGroupedSupportedTab(tabKey) && !backendGroupedFilterState(tabKey).unsupported
|
||||
}
|
||||
|
||||
function tableFilterSource (tableKey) {
|
||||
if (backendGroupedSupportedTab(tableKey)) {
|
||||
return { rows: backendGroupedRows.value[tableKey] || [], columns: filterColumnsForTableKey(tableKey) }
|
||||
}
|
||||
if (tableKey === 'general') return { rows: generalRows.value, columns: generalColumns }
|
||||
if (tableKey === 'markets') return { rows: marketRows.value, columns: marketColumns }
|
||||
if (tableKey === 'countries') return { rows: countryRows.value, columns: countryColumns }
|
||||
if (tableKey === 'customers') return { rows: customerRows.value, columns: customerColumns }
|
||||
return null
|
||||
}
|
||||
|
||||
function columnsForTableKey (tabKey) {
|
||||
if (tabKey === 'products') return visibleProductColumns.value
|
||||
if (tabKey === 'product_detail') return visibleProductDetailColumns.value
|
||||
if (tabKey === 'general') return visibleGeneralColumns.value
|
||||
if (tabKey === 'order_product_customers') return orderProductCustomerColumns
|
||||
if (tabKey === 'order_market_details') return orderMarketDetailColumns
|
||||
if (tabKey === 'idle') return idleColumns
|
||||
if (tabKey === 'markets') return marketColumns
|
||||
if (tabKey === 'countries') return countryColumns
|
||||
if (tabKey === 'customers') return customerColumns
|
||||
if (salesBreakdownTabKeys.includes(tabKey)) return visibleSalesBreakdownColumns.value
|
||||
return productColumns.value
|
||||
}
|
||||
|
||||
function filterColumnsForTableKey (tabKey) {
|
||||
if (tabKey === 'products') return productColumns.value
|
||||
if (tabKey === 'product_detail') return productDetailColumns.value
|
||||
if (tabKey === 'general') return generalColumns
|
||||
if (tabKey === 'order_product_customers') return orderProductCustomerColumns
|
||||
if (tabKey === 'order_market_details') return orderMarketDetailColumns
|
||||
if (tabKey === 'idle') return idleColumns
|
||||
if (tabKey === 'markets') return marketColumns
|
||||
if (tabKey === 'countries') return countryColumns
|
||||
if (tabKey === 'customers') return customerColumns
|
||||
if (salesBreakdownTabKeys.includes(tabKey)) return visibleSalesBreakdownColumns.value
|
||||
return productColumns.value
|
||||
}
|
||||
|
||||
function collectGroupKeys (out, sourceRows, level, parentKeys, levels = groupLevels, maxLevel = levels.length - 1) {
|
||||
@@ -2765,8 +3249,55 @@ function sortProductLeafRows (sourceRows) {
|
||||
})
|
||||
}
|
||||
|
||||
function sortLeafRowsForTable (tableKey, sourceRows) {
|
||||
const { sortBy, descending } = tableSortState(tableKey)
|
||||
if (!sortBy) return sortProductLeafRows(sourceRows)
|
||||
return sortFlatTableRows(sourceRows, sortBy, descending)
|
||||
}
|
||||
|
||||
function tableSortState (tableKey) {
|
||||
const pagination = tablePagination[tableKey] || {}
|
||||
return {
|
||||
sortBy: pagination.sortBy || '',
|
||||
descending: pagination.descending !== false
|
||||
}
|
||||
}
|
||||
|
||||
function compareGroupedBucketsForTable (tableKey, left, right, groupDef) {
|
||||
const [leftLabel, leftRows] = left
|
||||
const [rightLabel, rightRows] = right
|
||||
const { sortBy, descending } = tableSortState(tableKey)
|
||||
if (!sortBy || sortBy === groupDef.key) {
|
||||
return String(leftLabel).localeCompare(String(rightLabel), 'tr', { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
|
||||
const leftValue = groupedBucketSortValue(leftRows, sortBy)
|
||||
const rightValue = groupedBucketSortValue(rightRows, sortBy)
|
||||
const cmp = compareColumnSortValues(leftValue, rightValue)
|
||||
if (cmp !== 0) return descending ? -cmp : cmp
|
||||
return String(leftLabel).localeCompare(String(rightLabel), 'tr', { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
|
||||
function groupedBucketSortValue (sourceRows, sortBy) {
|
||||
if (!sourceRows?.length) return 0
|
||||
if (shouldUseDerivedGroupSortValue(sortBy)) {
|
||||
return sortValueForColumnName(aggregateGroupFields(sourceRows), sortBy)
|
||||
}
|
||||
if (sortBy === 'stock_qty') return distinctVariantStockQty(sourceRows)
|
||||
if (sortBy === 'idle_cost_usd') return distinctVariantStockCost(sourceRows)
|
||||
if (sortBy === 'base_price_usd' || sortBy === 'cost_price_usd') return weightedAverageProductCost(sourceRows, sortBy)
|
||||
if (shouldSumField(sortBy)) return sumRows(sourceRows, sortBy)
|
||||
if (shouldAverageField(sortBy)) return weightedAverageOrAverage(sourceRows, sortBy, weightFieldForMetric(sortBy))
|
||||
return sortValueForColumnName(sourceRows[0], sortBy)
|
||||
}
|
||||
|
||||
function shouldUseDerivedGroupSortValue (sortBy) {
|
||||
return /^(performance_score|stock_turnover|stock_days|avg_price_usd|unit_profit|gross_profit|gross_margin|market_count|customer_count|sales_index)/i.test(String(sortBy || ''))
|
||||
}
|
||||
|
||||
function sortProductGroupedTableRows (tableRows, sortBy, descending) {
|
||||
const source = Array.isArray(tableRows) ? tableRows : []
|
||||
if (backendGroupedSupportedTab(activeTab.value)) return source
|
||||
if (!sortBy || !source.some(row => row?.__group)) {
|
||||
return sortFlatTableRows(source, sortBy, descending)
|
||||
}
|
||||
@@ -2859,9 +3390,9 @@ function sortValueForColumnName (row, columnName) {
|
||||
function makeGroupRow (key, level, groupDef, value, groupRows) {
|
||||
const imageSource = groupRows.find(row => row?.product_code) || {}
|
||||
const aggregate = aggregateGroupFields(groupRows, groupDef.key)
|
||||
const colorSummary = groupDef.key === 'color_code' ? value : distinctSummary(groupRows, 'color_code')
|
||||
const yakaSummary = groupDef.key === 'yaka_kodu' ? value : distinctSummary(groupRows, 'yaka_kodu')
|
||||
const colorDescriptionSummary = distinctSummary(groupRows, 'color_description')
|
||||
const colorSummary = groupDef.key === 'color_code' ? value : ''
|
||||
const yakaSummary = groupDef.key === 'yaka_kodu' ? value : ''
|
||||
const colorDescriptionSummary = groupDef.key === 'color_code' ? distinctSummary(groupRows, 'color_description') : ''
|
||||
return {
|
||||
__group: true,
|
||||
row_key: `group|${key}`,
|
||||
@@ -2875,21 +3406,21 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
|
||||
image_color_code: imageSource.color_code || '',
|
||||
image_color_description: imageSource.color_description || '',
|
||||
image_yaka_kodu: imageSource.yaka_kodu || '',
|
||||
product_code: groupDef.key === 'product_code' ? value : distinctSummary(groupRows, 'product_code'),
|
||||
product_code: groupDef.key === 'product_code' ? value : '',
|
||||
color_code: colorSummary,
|
||||
color_description: colorDescriptionSummary,
|
||||
yaka_kodu: yakaSummary,
|
||||
item_description: distinctSummary(groupRows, 'item_description'),
|
||||
kategori: groupDef.key === 'kategori' ? value : distinctSummary(groupRows, 'kategori'),
|
||||
urun_ilk_grubu: groupDef.key === 'urun_ilk_grubu' ? value : distinctSummary(groupRows, 'urun_ilk_grubu'),
|
||||
askili_yan: groupDef.key === 'askili_yan' ? value : distinctSummary(groupRows, 'askili_yan'),
|
||||
urun_ana_grubu: groupDef.key === 'urun_ana_grubu' ? value : distinctSummary(groupRows, 'urun_ana_grubu'),
|
||||
urun_alt_grubu: groupDef.key === 'urun_alt_grubu' ? value : distinctSummary(groupRows, 'urun_alt_grubu'),
|
||||
market_key: groupDef.key === 'market_key' ? value : distinctSummary(groupRows, 'market_key'),
|
||||
country: groupDef.key === 'country' ? value : distinctSummary(groupRows, 'country'),
|
||||
customer_segment: groupDef.key === 'customer_segment' ? value : distinctSummary(groupRows, 'customer_segment'),
|
||||
customer_code: groupDef.key === 'customer_code' ? value : distinctSummary(groupRows, 'customer_code'),
|
||||
customer_name: groupDef.key === 'customer_name' ? value : distinctSummary(groupRows, 'customer_name'),
|
||||
item_description: groupDef.key === 'item_description' ? value : '',
|
||||
kategori: groupDef.key === 'kategori' ? value : '',
|
||||
urun_ilk_grubu: groupDef.key === 'urun_ilk_grubu' ? value : '',
|
||||
askili_yan: groupDef.key === 'askili_yan' ? value : '',
|
||||
urun_ana_grubu: groupDef.key === 'urun_ana_grubu' ? value : '',
|
||||
urun_alt_grubu: groupDef.key === 'urun_alt_grubu' ? value : '',
|
||||
market_key: groupDef.key === 'market_key' ? value : '',
|
||||
country: groupDef.key === 'country' ? value : '',
|
||||
customer_segment: groupDef.key === 'customer_segment' ? value : '',
|
||||
customer_code: groupDef.key === 'customer_code' ? value : '',
|
||||
customer_name: groupDef.key === 'customer_name' ? value : '',
|
||||
stock_qty: sumRows(groupRows, 'stock_qty'),
|
||||
sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'),
|
||||
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
|
||||
@@ -2902,10 +3433,10 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
|
||||
gross_profit_usd_90d: sumRows(groupRows, 'gross_profit_usd_90d'),
|
||||
gross_profit_usd_180d: sumRows(groupRows, 'gross_profit_usd_180d'),
|
||||
gross_profit_usd_total: sumRows(groupRows, 'gross_profit_usd_total'),
|
||||
market_count_90d: distinctCount(groupRows, 'market_key'),
|
||||
customer_count_90d: sumRows(groupRows, 'customer_count_90d'),
|
||||
market_count_total: sumRows(groupRows, 'market_count_total'),
|
||||
customer_count_total: sumRows(groupRows, 'customer_count_total'),
|
||||
market_count_90d: distinctSpreadCount(groupRows, 'market_count_90d'),
|
||||
customer_count_90d: distinctSpreadCount(groupRows, 'customer_count_90d'),
|
||||
market_count_total: distinctSpreadCount(groupRows, 'market_count_total'),
|
||||
customer_count_total: distinctSpreadCount(groupRows, 'customer_count_total'),
|
||||
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_price_usd_365d: weightedAverage(groupRows, 'sales_usd_365d', 'sales_qty_365d'),
|
||||
@@ -2933,7 +3464,9 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
|
||||
sales_index_total: averageRows(groupRows, 'sales_index_total'),
|
||||
performance_bucket: dominantValue(groupRows, 'performance_bucket'),
|
||||
...aggregate,
|
||||
performance_score: groupPerformanceScore(aggregate, groupDef.key),
|
||||
performance_score: Object.prototype.hasOwnProperty.call(aggregate, 'performance_score')
|
||||
? aggregate.performance_score
|
||||
: groupPerformanceScore(aggregate, groupDef.key),
|
||||
recommendation: dominantValue(groupRows, 'recommendation') || `${formatNumber(groupRows.length, 0)} satır`
|
||||
}
|
||||
}
|
||||
@@ -2947,8 +3480,9 @@ function aggregateGroupFields (sourceRows, groupField = '') {
|
||||
|
||||
for (const field of fields) {
|
||||
if (field === 'row_key' || field === 'key') continue
|
||||
if (field === 'stock_qty') {
|
||||
out[field] = distinctVariantStockQty(sourceRows)
|
||||
if (/^gross_margin(_base|_cost)?(_|$)/i.test(field)) continue
|
||||
if (field === 'stock_qty' || /^avg_stock_/i.test(field)) {
|
||||
out[field] = distinctVariantNumber(sourceRows, field)
|
||||
continue
|
||||
}
|
||||
if (field === 'idle_cost_usd') {
|
||||
@@ -2959,6 +3493,10 @@ function aggregateGroupFields (sourceRows, groupField = '') {
|
||||
out[field] = weightedAverageProductCost(sourceRows, field)
|
||||
continue
|
||||
}
|
||||
if (isDistinctSpreadCountField(field)) {
|
||||
out[field] = distinctSpreadCount(sourceRows, field)
|
||||
continue
|
||||
}
|
||||
if (shouldSumField(field)) {
|
||||
out[field] = sumRows(sourceRows, field)
|
||||
} else if (shouldAverageField(field)) {
|
||||
@@ -2971,15 +3509,18 @@ function aggregateGroupFields (sourceRows, groupField = '') {
|
||||
}
|
||||
|
||||
function shouldSumField (field) {
|
||||
return /(_qty|_usd|_count|_value_usd|line_count|invoice_count|order_count|product_count|market_count|customer_count|overdue_qty|stock_qty|net_stock_after_order|idle_cost_usd)$/i.test(field)
|
||||
return /^(sales_qty_|sales_usd_|avg_daily_sales_|gross_profit_|invoice_count_|market_count_|customer_count_|product_group_count_|product_count_)/i.test(field) ||
|
||||
/(_qty|_usd|_count|_value_usd|line_count|invoice_count|order_count|product_count|market_count|customer_count|overdue_qty|stock_qty|net_stock_after_order|idle_cost_usd)$/i.test(field)
|
||||
}
|
||||
|
||||
function shouldAverageField (field) {
|
||||
return /^(avg_|unit_|base_price|cost_price|gross_margin|expected_margin|sales_index|performance_score|stock_days|stock_turnover)/i.test(field) ||
|
||||
if (/^avg_daily_sales_/i.test(field)) return false
|
||||
return /^(avg_|unit_|base_price|cost_price|gross_margin|expected_margin|sales_index|performance_score|customer_score|stock_days|stock_turnover)/i.test(field) ||
|
||||
/(_price_usd|_margin|_index|_days)$/i.test(field)
|
||||
}
|
||||
|
||||
function weightFieldForMetric (field) {
|
||||
if (field.startsWith('stock_turnover')) return `sales_qty_${countPeriodSuffix(field)}`
|
||||
if (field.includes('_180d')) return 'sales_qty_180d'
|
||||
if (field.includes('_365d')) return 'sales_qty_365d'
|
||||
if (field.includes('_total')) return 'sales_qty_total'
|
||||
@@ -2989,21 +3530,65 @@ function weightFieldForMetric (field) {
|
||||
}
|
||||
|
||||
function applyDerivedGroupMetrics (out, sourceRows, groupField = '') {
|
||||
Object.assign(out, normalizeProductCostFields(out))
|
||||
const isGroup = Boolean(String(groupField || '').trim())
|
||||
const preservedCustomerScores = {}
|
||||
const preservedProductScores = {}
|
||||
const preservedStockTurnovers = {}
|
||||
for (const suffix of ['90d', '180d', '365d', 'total']) {
|
||||
if (Object.prototype.hasOwnProperty.call(out, `customer_score_${suffix}`)) {
|
||||
preservedCustomerScores[suffix] = Number(out[`customer_score_${suffix}`] || 0)
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(out, `performance_score_${suffix}`)) {
|
||||
preservedProductScores[suffix] = Number(out[`performance_score_${suffix}`] || 0)
|
||||
}
|
||||
if (isGroup && Object.prototype.hasOwnProperty.call(out, `stock_turnover_${suffix}`)) {
|
||||
preservedStockTurnovers[suffix] = Number(out[`stock_turnover_${suffix}`] || 0)
|
||||
}
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(out, 'performance_score') &&
|
||||
!Object.prototype.hasOwnProperty.call(preservedProductScores, '90d')
|
||||
) {
|
||||
preservedProductScores['90d'] = Number(out.performance_score || 0)
|
||||
}
|
||||
for (const suffix of ['90d', '180d', '365d', 'total']) {
|
||||
const sales = Number(out[`sales_usd_${suffix}`] || 0)
|
||||
const qty = Number(out[`sales_qty_${suffix}`] || 0)
|
||||
if (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty
|
||||
if (sales > 0) {
|
||||
if (out[`gross_profit_base_usd_${suffix}`] !== undefined) {
|
||||
out[`gross_margin_base_${suffix}`] = Number(out[`gross_profit_base_usd_${suffix}`] || 0) / sales
|
||||
}
|
||||
if (out[`gross_profit_cost_usd_${suffix}`] !== undefined) {
|
||||
out[`gross_margin_cost_${suffix}`] = Number(out[`gross_profit_cost_usd_${suffix}`] || 0) / sales
|
||||
}
|
||||
if (out[`gross_profit_usd_${suffix}`] !== undefined) {
|
||||
out[`gross_margin_${suffix}`] = Number(out[`gross_profit_usd_${suffix}`] || 0) / sales
|
||||
}
|
||||
const stockQty = Number(out.stock_qty || 0)
|
||||
const avgStock = Number(out[`avg_stock_${suffix}`] || 0)
|
||||
const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty)
|
||||
const days = productPerformancePeriodDays(out, suffix)
|
||||
const avgDaily = days > 0 ? qty / days : Number(out[`avg_daily_sales_${suffix}`] || 0)
|
||||
if (days > 0) out[`avg_daily_sales_${suffix}`] = avgDaily
|
||||
if (avgDaily > 0 && turnoverBase > 0) out[`stock_days_${suffix}`] = turnoverBase / avgDaily
|
||||
else if (qty <= 0 && stockQty > 0) out[`stock_days_${suffix}`] = 9999
|
||||
else if (!Object.prototype.hasOwnProperty.call(out, `stock_days_${suffix}`)) out[`stock_days_${suffix}`] = 0
|
||||
out[`stock_turnover_${suffix}`] = annualizedStockTurnover(qty, turnoverBase, days)
|
||||
if (Object.prototype.hasOwnProperty.call(preservedStockTurnovers, suffix)) {
|
||||
out[`stock_turnover_${suffix}`] = preservedStockTurnovers[suffix]
|
||||
}
|
||||
if (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty
|
||||
const { costPrice, basePrice } = periodCostPair(out, suffix)
|
||||
out[`base_price_usd_${suffix}`] = basePrice
|
||||
out[`cost_price_usd_${suffix}`] = costPrice
|
||||
if (qty > 0) {
|
||||
const avgPrice = sales / qty
|
||||
out[`unit_profit_base_${suffix}`] = avgPrice - basePrice
|
||||
out[`unit_profit_cost_${suffix}`] = avgPrice - costPrice
|
||||
out[`gross_profit_base_usd_${suffix}`] = sales - qty * basePrice
|
||||
out[`gross_profit_cost_usd_${suffix}`] = sales - qty * costPrice
|
||||
out[`gross_profit_usd_${suffix}`] = out[`gross_profit_cost_usd_${suffix}`]
|
||||
} else {
|
||||
out[`unit_profit_base_${suffix}`] = 0
|
||||
out[`unit_profit_cost_${suffix}`] = 0
|
||||
out[`gross_profit_base_usd_${suffix}`] = 0
|
||||
out[`gross_profit_cost_usd_${suffix}`] = 0
|
||||
out[`gross_profit_usd_${suffix}`] = 0
|
||||
}
|
||||
out[`gross_margin_base_${suffix}`] = sales > 0 ? Number(out[`gross_profit_base_usd_${suffix}`] || 0) / sales : 0
|
||||
out[`gross_margin_cost_${suffix}`] = sales > 0 ? Number(out[`gross_profit_cost_usd_${suffix}`] || 0) / sales : 0
|
||||
out[`gross_margin_${suffix}`] = sales > 0 ? Number(out[`gross_profit_usd_${suffix}`] || 0) / sales : 0
|
||||
}
|
||||
|
||||
const orderUsd = Number(out.order_usd || 0)
|
||||
@@ -3021,15 +3606,33 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') {
|
||||
if (sourceRows.some(row => row.performance_bucket)) {
|
||||
out.performance_bucket = dominantValue(sourceRows, 'performance_bucket')
|
||||
}
|
||||
out.customer_score_90d = customerSalesPeriodScore(periodMetricSource(out, '90d'))
|
||||
out.customer_score_180d = customerSalesPeriodScore(periodMetricSource(out, '180d'))
|
||||
out.customer_score_365d = customerSalesPeriodScore(periodMetricSource(out, '365d'))
|
||||
out.customer_score_total = customerSalesPeriodScore(periodMetricSource(out, 'total'))
|
||||
out.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(out, '90d'))
|
||||
out.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(out, '180d'))
|
||||
out.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(out, '365d'))
|
||||
out.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(out, 'total'))
|
||||
out.performance_score = groupPerformanceScore(out, groupField)
|
||||
for (const suffix of ['90d', '180d', '365d', 'total']) {
|
||||
out[`customer_score_${suffix}`] = Object.prototype.hasOwnProperty.call(preservedCustomerScores, suffix)
|
||||
? preservedCustomerScores[suffix]
|
||||
: customerSalesPeriodScore(periodMetricSource(out, suffix))
|
||||
out[`performance_score_${suffix}`] = Object.prototype.hasOwnProperty.call(preservedProductScores, suffix)
|
||||
? preservedProductScores[suffix]
|
||||
: productSalesPeriodScore(productPeriodMetricSource(out, suffix))
|
||||
}
|
||||
out.performance_score = Number(out.performance_score_90d || 0)
|
||||
}
|
||||
|
||||
function productPerformancePeriodDays (row, suffix) {
|
||||
if (suffix === '90d') return 90
|
||||
if (suffix === '180d') return 180
|
||||
if (suffix === '365d') return 360
|
||||
if (suffix !== 'total') return 0
|
||||
const start = parseProductPerformanceDate(row?.period_start) || new Date(Date.UTC(2022, 0, 1))
|
||||
const end = parseProductPerformanceDate(row?.period_end) || parseProductPerformanceDate(row?.kpi_date)
|
||||
if (!start || !end || end < start) return 0
|
||||
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
|
||||
}
|
||||
|
||||
function parseProductPerformanceDate (value) {
|
||||
const text = String(value || '').trim().slice(0, 10)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return null
|
||||
const [year, month, day] = text.split('-').map(Number)
|
||||
return new Date(Date.UTC(year, month - 1, day))
|
||||
}
|
||||
|
||||
function groupPerformanceScore (row, groupField = '') {
|
||||
@@ -3043,33 +3646,39 @@ function salesGroupPerformanceScore (row) {
|
||||
}
|
||||
|
||||
function productSalesPeriodScore (row) {
|
||||
const salesIndex = Number(row?.sales_index_90d || 0)
|
||||
const margin = Number(row?.gross_margin_cost_90d ?? 0)
|
||||
const invoices = Number(row?.invoice_count_90d || 0)
|
||||
const qty = Number(row?.sales_qty_90d || 0)
|
||||
let stockTurnover = Number(row?.stock_turnover_90d || 0)
|
||||
const stockQty = Number(row?.stock_qty || 0)
|
||||
if (!stockTurnover && stockQty > 0) stockTurnover = qty / stockQty
|
||||
return Math.min(35, Math.max(0, salesIndex) * 18) +
|
||||
Math.min(30, Math.max(0, margin) * 60) +
|
||||
Math.min(15, invoices * 1.5) +
|
||||
Math.min(10, qty / 10) +
|
||||
Math.min(10, Math.max(0, stockTurnover) * 5)
|
||||
const suffix = row?.suffix || '90d'
|
||||
const periodDays = productPerformancePeriodDays(row, suffix)
|
||||
return productScore100({
|
||||
suffix,
|
||||
periodDays,
|
||||
salesUSD: Number(row?.sales_usd_90d || 0),
|
||||
salesIndex: Number(row?.sales_index_90d || 0),
|
||||
margin: Number(row?.gross_margin_cost_90d ?? row?.gross_margin_90d ?? 0),
|
||||
stockTurnover: Number(row?.stock_turnover_90d || 0),
|
||||
marketCount: Number(row?.market_count_90d || 0),
|
||||
customerCount: Number(row?.customer_count_90d || 0)
|
||||
})
|
||||
}
|
||||
|
||||
function productPeriodMetricSource (row, suffix) {
|
||||
const salesIndex = Number(row?.[`sales_index_${suffix}`] || 0)
|
||||
const qty = Number(row?.[`sales_qty_${suffix}`] || 0)
|
||||
const stockQty = Number(row?.stock_qty || 0)
|
||||
const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0)
|
||||
const isGroup = Boolean(row?.__group || String(row?.group_field || '').trim())
|
||||
const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty)
|
||||
const turnoverKey = `stock_turnover_${suffix}`
|
||||
const hasTurnover = Object.prototype.hasOwnProperty.call(row || {}, turnoverKey)
|
||||
return {
|
||||
suffix,
|
||||
sales_index_90d: salesIndex,
|
||||
sales_usd_90d: Number(row?.[`sales_usd_${suffix}`] || 0),
|
||||
gross_margin_cost_90d: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
|
||||
gross_margin_90d: Number(row?.[`gross_margin_${suffix}`] ?? 0),
|
||||
invoice_count_90d: Number(row?.[`invoice_count_${suffix}`] || 0),
|
||||
sales_qty_90d: qty,
|
||||
stock_turnover_90d: hasTurnover ? Number(row?.[turnoverKey] || 0) : (stockQty > 0 ? qty / stockQty : 0),
|
||||
stock_turnover_90d: hasTurnover ? Number(row?.[turnoverKey] || 0) : annualizedStockTurnover(qty, turnoverBase, productPerformancePeriodDays(row, suffix)),
|
||||
market_count_90d: periodCount(row, 'market_count', suffix),
|
||||
customer_count_90d: periodCount(row, 'customer_count', suffix),
|
||||
stock_qty: stockQty
|
||||
}
|
||||
}
|
||||
@@ -3088,29 +3697,131 @@ function customerSalesGroupPerformanceScore (row) {
|
||||
}
|
||||
|
||||
function customerSalesPeriodScore (row) {
|
||||
const salesUSD = Number(row?.sales_usd || 0)
|
||||
const margin = Number(row?.gross_margin_cost ?? 0)
|
||||
const invoices = Number(row?.invoice_count || 0)
|
||||
const qty = Number(row?.sales_qty || 0)
|
||||
const productCount = Number(row?.product_count || 0)
|
||||
return Math.min(35, salesUSD / 1000) +
|
||||
Math.min(25, Math.max(0, margin) * 55) +
|
||||
Math.min(20, invoices * 2) +
|
||||
Math.min(10, qty / 10) +
|
||||
Math.min(10, productCount)
|
||||
return customerScore100({
|
||||
suffix: row?.suffix || '90d',
|
||||
salesUSD: Number(row?.sales_usd || 0),
|
||||
margin: Number(row?.gross_margin_cost ?? row?.gross_margin ?? 0),
|
||||
productGroupCount: Number(row?.product_group_count || row?.product_count || 0),
|
||||
salesQty: Number(row?.sales_qty || 0)
|
||||
})
|
||||
}
|
||||
|
||||
function periodMetricSource (row, suffix) {
|
||||
return {
|
||||
suffix,
|
||||
sales_usd: Number(row?.[`sales_usd_${suffix}`] || 0),
|
||||
gross_margin_cost: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
|
||||
gross_margin: Number(row?.[`gross_margin_${suffix}`] ?? 0),
|
||||
invoice_count: Number(row?.[`invoice_count_${suffix}`] || 0),
|
||||
sales_qty: Number(row?.[`sales_qty_${suffix}`] || 0),
|
||||
product_group_count: periodCount(row, 'product_group_count', suffix),
|
||||
product_count: Number(row?.product_count || 0)
|
||||
}
|
||||
}
|
||||
|
||||
function productScore100 ({ suffix = '90d', periodDays = 0, salesUSD = 0, margin = 0, stockTurnover = 0, marketCount = 0, customerCount = 0 } = {}) {
|
||||
if (Number(salesUSD || 0) <= 0) return 1
|
||||
const revenue = ratioScore(salesUSD, productRevenueTarget(suffix, periodDays))
|
||||
return clampScore(
|
||||
0.30 * marginScore(margin) +
|
||||
0.20 * ratioScore(stockTurnover, stockTurnoverTarget(suffix)) +
|
||||
0.20 * revenue +
|
||||
0.05 * ratioScore(marketCount, marketSpreadTarget(suffix)) +
|
||||
0.25 * ratioScore(customerCount, customerSpreadTarget(suffix, periodDays))
|
||||
)
|
||||
}
|
||||
|
||||
function customerScore100 ({ suffix = '90d', salesUSD = 0, margin = 0, productGroupCount = 0, salesQty = 0 } = {}) {
|
||||
if (Number(salesUSD || 0) <= 0) return 1
|
||||
return clampScore(
|
||||
0.35 * ratioScore(salesUSD, customerRevenueTarget(suffix)) +
|
||||
0.30 * marginScore(margin) +
|
||||
0.20 * ratioScore(productGroupCount, 8) +
|
||||
0.15 * ratioScore(salesQty, customerQtyTarget(suffix))
|
||||
)
|
||||
}
|
||||
|
||||
function ratioScore (value, target) {
|
||||
const n = Number(value || 0)
|
||||
const t = Number(target || 0)
|
||||
if (!Number.isFinite(n) || !Number.isFinite(t) || n <= 0 || t <= 0) return 0
|
||||
return Math.min(100, Math.max(0, n) * 100 / t)
|
||||
}
|
||||
|
||||
function marginScore (margin) {
|
||||
return ratioScore(Math.max(0, Number(margin || 0)), 0.45)
|
||||
}
|
||||
|
||||
function clampScore (score) {
|
||||
const n = Number(score || 0)
|
||||
if (!Number.isFinite(n) || n <= 0) return 0
|
||||
return Math.round(Math.min(100, n) * 10000) / 10000
|
||||
}
|
||||
|
||||
function productRevenueTarget (suffix, periodDays = 0) {
|
||||
if (suffix === '180d') return 50000
|
||||
if (suffix === '365d') return 100000
|
||||
if (suffix === 'total') return 50000 * totalPeriodMultiplier(periodDays)
|
||||
return 25000
|
||||
}
|
||||
|
||||
function customerRevenueTarget (suffix) {
|
||||
if (suffix === '180d') return 50000
|
||||
if (suffix === '365d') return 100000
|
||||
if (suffix === 'total') return 300000
|
||||
return 25000
|
||||
}
|
||||
|
||||
function customerQtyTarget (suffix) {
|
||||
if (suffix === '180d') return 1000
|
||||
if (suffix === '365d') return 2000
|
||||
if (suffix === 'total') return 6000
|
||||
return 500
|
||||
}
|
||||
|
||||
const STOCK_TURNOVER_YEAR_DAYS = 360
|
||||
|
||||
function annualizedStockTurnover (salesQty, avgStock, periodDays) {
|
||||
const qty = Number(salesQty || 0)
|
||||
const stock = Number(avgStock || 0)
|
||||
const days = Number(periodDays || 0)
|
||||
if (!Number.isFinite(qty) || !Number.isFinite(stock) || qty <= 0 || stock <= 0) return 0
|
||||
const raw = qty / stock
|
||||
return days > 0 ? raw * STOCK_TURNOVER_YEAR_DAYS / days : raw
|
||||
}
|
||||
|
||||
function stockTurnoverTarget () {
|
||||
return 4
|
||||
}
|
||||
|
||||
function marketSpreadTarget (suffix) {
|
||||
if (suffix === '90d') return 3
|
||||
if (suffix === '180d') return 3
|
||||
if (suffix === '365d') return 6
|
||||
return 6
|
||||
}
|
||||
|
||||
function customerSpreadTarget (suffix, periodDays = 0) {
|
||||
if (suffix === '90d') return 8
|
||||
if (suffix === '180d') return 20
|
||||
if (suffix === '365d') return 50
|
||||
return 20 * totalPeriodMultiplier(periodDays)
|
||||
}
|
||||
|
||||
function totalPeriodMultiplier (periodDays = 0) {
|
||||
let days = Number(periodDays || 0)
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
days = productPerformancePeriodDays({ kpi_date: new Date().toISOString().slice(0, 10) }, 'total')
|
||||
}
|
||||
return Math.max(1, days / 180)
|
||||
}
|
||||
|
||||
function periodCount (row, prefix, suffix) {
|
||||
const keyed = Number(row?.[`${prefix}_${suffix}`] || 0)
|
||||
if (keyed > 0) return keyed
|
||||
if (suffix === 'total') return Number(row?.[`${prefix}_total`] || 0)
|
||||
return Number(row?.[`${prefix}_90d`] || row?.[prefix] || 0)
|
||||
}
|
||||
|
||||
function orderGroupPerformanceScore (row) {
|
||||
const orderUSD = Number(row?.order_usd || 0)
|
||||
const margin = Number(row?.expected_margin_cost || 0)
|
||||
@@ -3137,19 +3848,23 @@ function productVariantStockKey (row) {
|
||||
}
|
||||
|
||||
function distinctVariantStockQty (sourceRows) {
|
||||
return distinctVariantNumber(sourceRows, 'stock_qty')
|
||||
}
|
||||
|
||||
function distinctVariantNumber (sourceRows, field) {
|
||||
const seen = new Set()
|
||||
let total = 0
|
||||
let keyed = false
|
||||
for (const row of sourceRows) {
|
||||
const key = productVariantStockKey(row)
|
||||
const stockQty = Number(row?.stock_qty || 0)
|
||||
const value = Number(row?.[field] || 0)
|
||||
if (!key) continue
|
||||
keyed = true
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
total += stockQty
|
||||
total += value
|
||||
}
|
||||
return keyed ? total : sumRows(sourceRows, 'stock_qty')
|
||||
return keyed ? total : sumRows(sourceRows, field)
|
||||
}
|
||||
|
||||
function distinctVariantStockCost (sourceRows) {
|
||||
@@ -3178,6 +3893,83 @@ function distinctCount (sourceRows, field) {
|
||||
return values.size
|
||||
}
|
||||
|
||||
function isDistinctSpreadCountField (field) {
|
||||
return /^market_count(_|$)/i.test(field) || /^customer_count(_|$)/i.test(field)
|
||||
}
|
||||
|
||||
function countPeriodSuffix (field) {
|
||||
if (field.includes('_180d')) return '180d'
|
||||
if (field.includes('_365d')) return '365d'
|
||||
if (field.includes('_total')) return 'total'
|
||||
return '90d'
|
||||
}
|
||||
|
||||
function salesUsdFieldForSuffix (suffix) {
|
||||
return `sales_usd_${suffix}`
|
||||
}
|
||||
|
||||
function distinctSpreadCount (sourceRows, field) {
|
||||
const suffix = countPeriodSuffix(field)
|
||||
return /^market_count/i.test(field)
|
||||
? distinctRevenueMarketCount(sourceRows, suffix, field)
|
||||
: distinctRevenueCustomerCount(sourceRows, suffix, field)
|
||||
}
|
||||
|
||||
function distinctRevenueMarketCount (sourceRows, suffix, fallbackField) {
|
||||
const salesField = salesUsdFieldForSuffix(suffix)
|
||||
const values = new Set()
|
||||
for (const row of sourceRows) {
|
||||
if (Number(row?.[salesField] || 0) <= 0) continue
|
||||
const marketKeys = stringListFromRow(row, `__market_keys_${suffix}`)
|
||||
for (const key of marketKeys) {
|
||||
const value = displayMarketName(key)
|
||||
if (value && value !== 'STOK' && value !== '-') values.add(value)
|
||||
}
|
||||
if (marketKeys.length > 0) continue
|
||||
const value = displayMarketName(row?.market_key)
|
||||
if (value && value !== 'STOK' && value !== '-') values.add(value)
|
||||
}
|
||||
if (values.size > 0) return values.size
|
||||
return sourceRows.reduce((sum, row) => {
|
||||
return Number(row?.[salesField] || 0) > 0 ? sum + Number(row?.[fallbackField] || 0) : sum
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function distinctRevenueCustomerCount (sourceRows, suffix, fallbackField) {
|
||||
const salesField = salesUsdFieldForSuffix(suffix)
|
||||
const values = new Set()
|
||||
for (const row of sourceRows) {
|
||||
if (Number(row?.[salesField] || 0) <= 0) continue
|
||||
const customerKeys = stringListFromRow(row, `__customer_keys_${suffix}`)
|
||||
for (const code of customerKeys) {
|
||||
if (code && code !== '-') values.add(code)
|
||||
}
|
||||
if (customerKeys.length > 0) continue
|
||||
const code = String(row?.customer_code || '').trim()
|
||||
if (code && code !== '-') values.add(code)
|
||||
}
|
||||
if (values.size > 0) return values.size
|
||||
return sourceRows.reduce((sum, row) => {
|
||||
return Number(row?.[salesField] || 0) > 0 ? sum + Number(row?.[fallbackField] || 0) : sum
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function stringListFromRow (row, field) {
|
||||
const value = row?.[field]
|
||||
if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean)
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return []
|
||||
if (text.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
return Array.isArray(parsed) ? parsed.map(item => String(item || '').trim()).filter(Boolean) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return [text]
|
||||
}
|
||||
|
||||
function distinctSummary (sourceRows, field, fallbackField) {
|
||||
const values = new Set()
|
||||
for (const row of sourceRows) {
|
||||
@@ -3226,6 +4018,9 @@ function weightedAverage (sourceRows, amountField, qtyField) {
|
||||
}
|
||||
|
||||
function weightedAverageOrAverage (sourceRows, valueField, qtyField) {
|
||||
if (/^(performance_score|customer_score)/i.test(valueField)) {
|
||||
return weightedAverageScore(sourceRows, valueField, scoreSuffixForMetric(valueField))
|
||||
}
|
||||
const weightedRows = sourceRows
|
||||
.map(row => ({
|
||||
value: Number(row[valueField] || 0),
|
||||
@@ -3239,6 +4034,34 @@ function weightedAverageOrAverage (sourceRows, valueField, qtyField) {
|
||||
return 0
|
||||
}
|
||||
|
||||
function scoreSuffixForMetric (field) {
|
||||
if (field.includes('_180d')) return '180d'
|
||||
if (field.includes('_365d')) return '365d'
|
||||
if (field.includes('_total')) return 'total'
|
||||
return '90d'
|
||||
}
|
||||
|
||||
function scoreWeight (row, suffix) {
|
||||
const salesQty = Number(row?.[`sales_qty_${suffix}`] || 0)
|
||||
if (salesQty > 0) return salesQty
|
||||
return 0
|
||||
}
|
||||
|
||||
function weightedAverageScore (sourceRows, valueField, suffix) {
|
||||
const rows = sourceRows
|
||||
.filter(row => Object.prototype.hasOwnProperty.call(row || {}, valueField))
|
||||
.map(row => ({
|
||||
value: Number(row[valueField] || 0),
|
||||
weight: scoreWeight(row, suffix)
|
||||
}))
|
||||
.filter(row => Number.isFinite(row.value) && Number.isFinite(row.weight))
|
||||
const weight = rows.reduce((sum, row) => sum + Math.max(0, row.weight), 0)
|
||||
if (weight > 0) {
|
||||
return rows.reduce((sum, row) => sum + row.value * Math.max(0, row.weight), 0) / weight
|
||||
}
|
||||
return rows.length ? rows.reduce((sum, row) => sum + row.value, 0) / rows.length : 0
|
||||
}
|
||||
|
||||
function weightedAverageProductCost (sourceRows, valueField) {
|
||||
const salesRows = sourceRows
|
||||
.map(row => ({
|
||||
@@ -3257,18 +4080,12 @@ function weightedAverageProductCost (sourceRows, valueField) {
|
||||
if (!key || variants.has(key)) continue
|
||||
variants.set(key, row)
|
||||
}
|
||||
const stockSource = variants.size > 0 ? Array.from(variants.values()) : sourceRows
|
||||
const stockRows = stockSource
|
||||
.map(row => ({
|
||||
value: Number(row[valueField] || 0),
|
||||
qty: Number(row.stock_qty || 0)
|
||||
}))
|
||||
.filter(row => Number.isFinite(row.value) && Number.isFinite(row.qty) && row.value > 0 && row.qty > 0)
|
||||
const stockQty = stockRows.reduce((sum, row) => sum + row.qty, 0)
|
||||
if (stockQty > 0) {
|
||||
return stockRows.reduce((sum, row) => sum + row.value * row.qty, 0) / stockQty
|
||||
}
|
||||
return 0
|
||||
const averageSource = variants.size > 0 ? Array.from(variants.values()) : sourceRows
|
||||
const values = averageSource
|
||||
.map(row => Number(row[valueField] || 0))
|
||||
.filter(value => Number.isFinite(value) && value > 0)
|
||||
if (!values.length) return 0
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function marginFromSalesCost (salesUSD, qty, unitCost) {
|
||||
@@ -3287,63 +4104,120 @@ function marginFromRows (sourceRows, salesField, qtyField, unitCostField) {
|
||||
}
|
||||
|
||||
function normalizeGroupValue (value) {
|
||||
return String(value || '').trim()
|
||||
return String(value || '').trim() || '(Boş)'
|
||||
}
|
||||
|
||||
function shouldSkipGroupValue (field, value) {
|
||||
const text = String(value || '').trim()
|
||||
if (field === 'urun_ilk_grubu') return !text || text === '-'
|
||||
if (field === 'askili_yan') return !text || text === '-'
|
||||
return false
|
||||
}
|
||||
|
||||
function groupCanExpand (rowOrKey) {
|
||||
const key = typeof rowOrKey === 'string' ? rowOrKey : rowOrKey?.key
|
||||
const depth = groupKeyDepth(key)
|
||||
const tabKey = tabKeyFromGroupKey(key)
|
||||
const levels = tabGroupLevels[tabKey] || groupLevels
|
||||
return depth >= 0 && depth < levels.length - 1
|
||||
}
|
||||
|
||||
function isGroupExpanded (key) {
|
||||
return expandedGroups.value[key] === true
|
||||
if (!groupCanExpand(key)) return false
|
||||
const tabKey = tabKeyFromGroupKey(key)
|
||||
const depth = groupKeyDepth(key)
|
||||
const level = selectedExpandThroughLevelForTab(tabKey)
|
||||
return depth >= 0 && level >= depth
|
||||
}
|
||||
|
||||
function toggleGroup (key) {
|
||||
lastManualExpandedGroupKey.value = key
|
||||
expandedGroups.value = {
|
||||
...expandedGroups.value,
|
||||
[key]: !isGroupExpanded(key)
|
||||
}
|
||||
scheduleLoadBackendGroupedRows()
|
||||
function groupKeyDepth (key) {
|
||||
const parts = String(key || '').split('|').filter(Boolean)
|
||||
return Math.max(-1, parts.length - 2)
|
||||
}
|
||||
|
||||
function toggleAllProductGroups () {
|
||||
const keys = productAutoExpandKeys.value
|
||||
if (allProductGroupsExpanded.value) {
|
||||
collapseAllProductGroups()
|
||||
return
|
||||
}
|
||||
expandSelectedProductGroups()
|
||||
function tabKeyFromGroupKey (key) {
|
||||
const first = String(key || '').split('|')[0] || ''
|
||||
return first.startsWith('tab:') ? first.slice(4) : activeTab.value
|
||||
}
|
||||
|
||||
function collapseAllProductGroups () {
|
||||
expandedGroups.value = {}
|
||||
lastManualExpandedGroupKey.value = ''
|
||||
scheduleLoadBackendGroupedRows()
|
||||
function selectedExpandThroughLevelForTab (tabKey) {
|
||||
const levels = tabGroupLevels[tabKey] || groupLevels
|
||||
const selected = new Set(selectedExpandLevelKeysByTab[tabKey] || [])
|
||||
const indexes = levels
|
||||
.map((level, index) => selected.has(level.key) ? index : -1)
|
||||
.filter(index => index >= 0)
|
||||
return indexes.length ? Math.max(...indexes) : -1
|
||||
}
|
||||
|
||||
function expandSelectedProductGroups () {
|
||||
const keys = productAutoExpandKeys.value
|
||||
const expandableKeys = keys.slice(0, maxBulkExpandKeys)
|
||||
const tabPrefix = `tab:${activeTab.value}|`
|
||||
function activeTabPrefix () {
|
||||
return `tab:${activeTab.value}|`
|
||||
}
|
||||
|
||||
function expandedLevelKeysThrough (level) {
|
||||
if (level < 0) return []
|
||||
const keys = []
|
||||
collectGroupKeys(keys, activeGroupSourceRows.value, 0, [`tab:${activeTab.value}`], activeGroupLevels.value, level)
|
||||
return keys
|
||||
}
|
||||
|
||||
function replaceActiveTabExpandedGroups (keys) {
|
||||
const tabPrefix = activeTabPrefix()
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith(tabPrefix))
|
||||
)
|
||||
for (const key of expandableKeys) next[key] = true
|
||||
for (const key of keys) next[key] = true
|
||||
expandedGroups.value = next
|
||||
}
|
||||
|
||||
function setSelectedExpandLevelsThrough (level) {
|
||||
activeSelectedExpandLevelKeys.value = level < 0
|
||||
? []
|
||||
: activeGroupLevels.value.slice(0, level + 1).map(item => item.key)
|
||||
}
|
||||
|
||||
function applyActiveExpansionThroughLevel (level) {
|
||||
setSelectedExpandLevelsThrough(level)
|
||||
replaceActiveTabExpandedGroups([])
|
||||
lastManualExpandedGroupKey.value = ''
|
||||
if (keys.length > expandableKeys.length) {
|
||||
Notify.create({
|
||||
type: 'warning',
|
||||
message: `${formatNumber(expandableKeys.length, 0)} grup açıldı. Daha derin detaylar için satırları kademeli açın.`
|
||||
})
|
||||
}
|
||||
scheduleLoadBackendGroupedRows()
|
||||
}
|
||||
|
||||
function toggleGroup (key) {
|
||||
if (!groupCanExpand(key)) return
|
||||
const level = groupKeyDepth(key)
|
||||
applyActiveExpansionThroughLevel(isGroupExpanded(key) ? level - 1 : level)
|
||||
}
|
||||
|
||||
async function toggleAllProductGroups () {
|
||||
if (allProductGroupsExpanded.value) await collapseAllProductGroups()
|
||||
else {
|
||||
detailLevelMenuOpen.value = false
|
||||
applyActiveExpansionThroughLevel(fullExpandThroughLevel.value)
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
async function collapseAllProductGroups () {
|
||||
if (bulkExpandLoading.value) return
|
||||
detailLevelMenuOpen.value = false
|
||||
applyActiveExpansionThroughLevel(-1)
|
||||
bulkExpandLoading.value = true
|
||||
try {
|
||||
await nextTick()
|
||||
} finally {
|
||||
bulkExpandLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function expandSelectedProductGroups () {
|
||||
if (bulkExpandLoading.value) return
|
||||
detailLevelMenuOpen.value = false
|
||||
applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
|
||||
bulkExpandLoading.value = true
|
||||
try {
|
||||
await nextTick()
|
||||
} finally {
|
||||
bulkExpandLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function groupLabelColumnName (row) {
|
||||
const field = String(row?.group_field || '')
|
||||
if (field === 'color_code' || field === 'yaka_kodu' || field === 'color_yaka') return 'color_yaka'
|
||||
@@ -3381,6 +4255,12 @@ function groupShowsImage (row) {
|
||||
|
||||
const groupDimensionFields = new Set(dimensionColumnNames)
|
||||
|
||||
function groupRowClasses (row) {
|
||||
const level = Math.min(Number(row?.level || 0), 5)
|
||||
const field = String(row?.group_field || 'unknown').replace(/[^a-z0-9_-]/gi, '-').toLowerCase()
|
||||
return ['group-row', `group-row-level-${level}`, `group-row-field-${field}`]
|
||||
}
|
||||
|
||||
function groupCellClass (colOrName) {
|
||||
const name = colOrName?.name || colOrName
|
||||
if (name === 'image') return 'product-image-cell'
|
||||
@@ -3400,37 +4280,114 @@ function formatGroupCell (row, colOrName) {
|
||||
}
|
||||
|
||||
function withProductMargins (row) {
|
||||
return {
|
||||
...row,
|
||||
avg_price_usd_365d: Number(row?.sales_qty_365d || 0) > 0 ? Number(row?.sales_usd_365d || 0) / Number(row?.sales_qty_365d || 0) : 0,
|
||||
stock_turnover_90d: existingOrStockTurnover(row?.stock_turnover_90d, row?.sales_qty_90d, row?.stock_qty),
|
||||
stock_turnover_180d: existingOrStockTurnover(row?.stock_turnover_180d, row?.sales_qty_180d, row?.stock_qty),
|
||||
stock_turnover_365d: existingOrStockTurnover(row?.stock_turnover_365d, row?.sales_qty_365d, row?.stock_qty),
|
||||
gross_margin_base_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.base_price_usd),
|
||||
gross_margin_cost_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.cost_price_usd),
|
||||
gross_margin_base_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.base_price_usd),
|
||||
gross_margin_cost_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.cost_price_usd)
|
||||
const next = normalizeProductCostFields(row)
|
||||
const out = {
|
||||
...next,
|
||||
avg_price_usd_365d: Number(next?.sales_qty_365d || 0) > 0 ? Number(next?.sales_usd_365d || 0) / Number(next?.sales_qty_365d || 0) : 0,
|
||||
stock_turnover_90d: groupedOrExistingStockTurnover(next, '90d', 90),
|
||||
stock_turnover_180d: groupedOrExistingStockTurnover(next, '180d', 180),
|
||||
stock_turnover_365d: groupedOrExistingStockTurnover(next, '365d', 360),
|
||||
stock_turnover_total: groupedOrExistingStockTurnover(next, 'total', productPerformancePeriodDays(next, 'total'))
|
||||
}
|
||||
for (const suffix of ['90d', '180d', '365d', 'total']) {
|
||||
applyPeriodProfitFields(out, suffix)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function withGeneralMargins (row) {
|
||||
const out = normalizeProductCostFields(row)
|
||||
applyPeriodProfitFields(out, 'total')
|
||||
return {
|
||||
...row,
|
||||
stock_turnover_total: existingOrStockTurnover(row?.stock_turnover_total, row?.sales_qty_total, row?.stock_qty),
|
||||
gross_margin_base_total: marginFromSalesCost(row?.sales_usd_total, row?.sales_qty_total, row?.base_price_usd),
|
||||
gross_margin_cost_total: marginFromSalesCost(row?.sales_usd_total, row?.sales_qty_total, row?.cost_price_usd)
|
||||
...out,
|
||||
stock_turnover_total: groupedOrExistingStockTurnover(out, 'total', productPerformancePeriodDays(out, 'total'))
|
||||
}
|
||||
}
|
||||
|
||||
function stockTurnover (salesQty, stockQty) {
|
||||
const stock = Number(stockQty || 0)
|
||||
if (stock <= 0) return 0
|
||||
return Number(salesQty || 0) / stock
|
||||
function normalizeProductCostFields (row) {
|
||||
const next = { ...row }
|
||||
normalizeProductCostPairFields(next, 'cost_price_usd', 'base_price_usd')
|
||||
for (const suffix of ['90d', '180d', '365d', 'total']) {
|
||||
normalizeProductCostPairFields(next, `cost_price_usd_${suffix}`, `base_price_usd_${suffix}`)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function existingOrStockTurnover (value, salesQty, stockQty) {
|
||||
function normalizeProductCostPairFields (row, costField, baseField) {
|
||||
const hasCost = Object.prototype.hasOwnProperty.call(row || {}, costField)
|
||||
const hasBase = Object.prototype.hasOwnProperty.call(row || {}, baseField)
|
||||
if (!hasCost && !hasBase) return
|
||||
let costPrice = Number(row?.[costField] || 0)
|
||||
let basePrice = Number(row?.[baseField] || 0)
|
||||
if (costPrice > 0 && basePrice > 0 && costPrice > basePrice) {
|
||||
const tmp = costPrice
|
||||
costPrice = basePrice
|
||||
basePrice = tmp
|
||||
}
|
||||
if (hasCost || costPrice > 0) row[costField] = costPrice
|
||||
if (hasBase || basePrice > 0) row[baseField] = basePrice
|
||||
}
|
||||
|
||||
function periodCostPair (row, suffix) {
|
||||
const costField = `cost_price_usd_${suffix}`
|
||||
const baseField = `base_price_usd_${suffix}`
|
||||
let costPrice = Number(row?.[costField] || 0)
|
||||
let basePrice = Number(row?.[baseField] || 0)
|
||||
if (costPrice <= 0) costPrice = Number(row?.cost_price_usd || 0)
|
||||
if (basePrice <= 0) basePrice = Number(row?.base_price_usd || 0)
|
||||
if (costPrice > 0 && basePrice > 0 && costPrice > basePrice) {
|
||||
const tmp = costPrice
|
||||
costPrice = basePrice
|
||||
basePrice = tmp
|
||||
}
|
||||
return { costPrice, basePrice }
|
||||
}
|
||||
|
||||
function applyPeriodProfitFields (row, suffix) {
|
||||
const qty = Number(row?.[`sales_qty_${suffix}`] || 0)
|
||||
const salesUSD = Number(row?.[`sales_usd_${suffix}`] || 0)
|
||||
const { costPrice, basePrice } = periodCostPair(row, suffix)
|
||||
row[`base_price_usd_${suffix}`] = basePrice
|
||||
row[`cost_price_usd_${suffix}`] = costPrice
|
||||
if (qty > 0) {
|
||||
row[`avg_price_usd_${suffix}`] = salesUSD / qty
|
||||
row[`unit_profit_base_${suffix}`] = salesUSD / qty - basePrice
|
||||
row[`unit_profit_cost_${suffix}`] = salesUSD / qty - costPrice
|
||||
row[`gross_profit_base_usd_${suffix}`] = salesUSD - qty * basePrice
|
||||
row[`gross_profit_cost_usd_${suffix}`] = salesUSD - qty * costPrice
|
||||
row[`gross_profit_usd_${suffix}`] = row[`gross_profit_cost_usd_${suffix}`]
|
||||
} else {
|
||||
row[`unit_profit_base_${suffix}`] = 0
|
||||
row[`unit_profit_cost_${suffix}`] = 0
|
||||
row[`gross_profit_base_usd_${suffix}`] = 0
|
||||
row[`gross_profit_cost_usd_${suffix}`] = 0
|
||||
row[`gross_profit_usd_${suffix}`] = 0
|
||||
}
|
||||
row[`gross_margin_base_${suffix}`] = marginFromSalesCost(salesUSD, qty, basePrice)
|
||||
row[`gross_margin_cost_${suffix}`] = marginFromSalesCost(salesUSD, qty, costPrice)
|
||||
}
|
||||
|
||||
function stockTurnover (salesQty, stockQty, periodDays) {
|
||||
return annualizedStockTurnover(salesQty, stockQty, periodDays)
|
||||
}
|
||||
|
||||
function existingOrStockTurnover (value, salesQty, stockQty, periodDays) {
|
||||
const current = Number(value)
|
||||
return value !== undefined && value !== null && Number.isFinite(current) ? current : stockTurnover(salesQty, stockQty)
|
||||
return value !== undefined && value !== null && Number.isFinite(current) ? current : stockTurnover(salesQty, stockQty, periodDays)
|
||||
}
|
||||
|
||||
function groupedOrExistingStockTurnover (row, suffix, periodDays) {
|
||||
const salesQty = Number(row?.[`sales_qty_${suffix}`] || 0)
|
||||
const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0)
|
||||
const stockQty = Number(row?.stock_qty || 0)
|
||||
if (row?.__group) {
|
||||
const current = Number(row?.[`stock_turnover_${suffix}`])
|
||||
if (Number.isFinite(current)) return current
|
||||
if (avgStock > 0) return stockTurnover(salesQty, avgStock, periodDays)
|
||||
return 0
|
||||
}
|
||||
const stockBase = avgStock > 0 ? avgStock : stockQty
|
||||
return existingOrStockTurnover(row?.[`stock_turnover_${suffix}`], salesQty, stockBase, periodDays)
|
||||
}
|
||||
|
||||
function filterKey (tableKey, name) {
|
||||
@@ -3442,6 +4399,7 @@ function isColumnFilterable (name) {
|
||||
}
|
||||
|
||||
function columnFilterOptions (tableKey, name) {
|
||||
if (backendGroupedSupportedTab(tableKey) && !backendGroupedFilterFields.has(name)) return []
|
||||
const options = columnFilterOptionMap.value[tableKey]?.[name] || []
|
||||
const search = String(columnFilterSearch[filterKey(tableKey, name)] || '').trim().toLocaleLowerCase('tr')
|
||||
if (!search) return options
|
||||
@@ -3493,36 +4451,26 @@ function selectAllColumnFilterOptions (tableKey, name) {
|
||||
}
|
||||
|
||||
function runWithFilterBusy (apply) {
|
||||
filterBusy.value = true
|
||||
const done = () => {
|
||||
filterBusy.value = false
|
||||
}
|
||||
const execute = () => {
|
||||
apply()
|
||||
if (typeof window === 'undefined') {
|
||||
setTimeout(done, 0)
|
||||
return
|
||||
}
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(done)
|
||||
})
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
execute()
|
||||
return
|
||||
}
|
||||
window.requestAnimationFrame(execute)
|
||||
apply()
|
||||
syncExpansionAfterFilterChange()
|
||||
}
|
||||
|
||||
function syncExpansionAfterFilterChange () {
|
||||
if (!backendGroupedSupportedTab(activeTab.value)) return
|
||||
const level = selectedExpandThroughLevel.value
|
||||
applyActiveExpansionThroughLevel(level >= 0 ? autoExpandThroughLevel.value : -1)
|
||||
}
|
||||
|
||||
function filterRowsForTable (tableKey, sourceRows, sourceColumns) {
|
||||
return sourceRows.filter(row => {
|
||||
return sourceColumns.every(col => {
|
||||
if (!isColumnFilterable(col.name)) return true
|
||||
const selected = selectedColumnFilters(tableKey, col.name)
|
||||
if (!selected.length) return true
|
||||
return selected.includes(columnFilterValue(row, col))
|
||||
})
|
||||
})
|
||||
const activeFilters = sourceColumns
|
||||
.filter(col => isColumnFilterable(col.name))
|
||||
.map(col => ({
|
||||
col,
|
||||
selected: new Set(selectedColumnFilters(tableKey, col.name))
|
||||
}))
|
||||
.filter(item => item.selected.size > 0)
|
||||
if (!activeFilters.length) return sourceRows
|
||||
return sourceRows.filter(row => activeFilters.every(item => item.selected.has(columnFilterValue(row, item.col))))
|
||||
}
|
||||
|
||||
function columnFilterValue (row, col) {
|
||||
@@ -3540,6 +4488,12 @@ function colorYakaValue (row) {
|
||||
const colorDescription = String(row?.image_color_description || row?.color_description || '').trim()
|
||||
const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
|
||||
const colorLabel = colorDisplayLabel(groupField === 'color_code' ? groupValue || color : color, colorDescription)
|
||||
if (row?.__group && groupField === 'color_yaka') {
|
||||
const parts = groupValue.split('/').map(part => part.trim()).filter(Boolean)
|
||||
const groupColor = parts[0] || ''
|
||||
const groupYaka = parts.slice(1).join('/')
|
||||
return [colorDisplayLabel(groupColor, colorDescription), groupYaka].filter(value => value && value !== '-').join('/')
|
||||
}
|
||||
if (row?.__group && groupField === 'color_code') return colorLabel
|
||||
if (row?.__group && groupField === 'yaka_kodu') {
|
||||
const parentColor = colorLabel && colorLabel !== '-' ? colorLabel : ''
|
||||
@@ -3578,7 +4532,13 @@ function productFirstGroupValue (row) {
|
||||
}
|
||||
|
||||
function productMainGroupValue (row) {
|
||||
return row?.urun_ana_grubu || ''
|
||||
const direct = String(row?.urun_ana_grubu || '').trim()
|
||||
if (direct) return direct
|
||||
if (row?.__group && row.group_field === 'urun_ana_grubu') {
|
||||
const groupValue = String(row.group_value || row.label || '').trim()
|
||||
if (groupValue) return groupValue
|
||||
}
|
||||
return String(row?.urun_alt_grubu || row?.kategori || '').trim()
|
||||
}
|
||||
|
||||
function cleanOptionalGroupValue (value) {
|
||||
@@ -3677,7 +4637,7 @@ function parseSortNumber (value) {
|
||||
if (!text) return NaN
|
||||
const normalized = text
|
||||
.replace(/\s/g, '')
|
||||
.replace(/[%$₺€£]/g, '')
|
||||
.replace(/[%$\u20ba\u20ac\u00a3]/g, '')
|
||||
.replace(/\.(?=\d{3}(\D|$))/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.-]/g, '')
|
||||
@@ -3765,11 +4725,21 @@ function formatProductCell (row, name) {
|
||||
case 'gross_profit_usd_90d':
|
||||
case 'gross_profit_usd_180d':
|
||||
case 'gross_profit_usd_total':
|
||||
case 'gross_profit_base_usd_90d':
|
||||
case 'gross_profit_cost_usd_90d':
|
||||
case 'gross_profit_base_usd_180d':
|
||||
case 'gross_profit_cost_usd_180d':
|
||||
case 'gross_profit_base_usd_365d':
|
||||
case 'gross_profit_cost_usd_365d':
|
||||
case 'gross_profit_base_usd_total':
|
||||
case 'gross_profit_cost_usd_total':
|
||||
return formatMoney(row[name], 'USD')
|
||||
case 'gross_margin_base_90d':
|
||||
case 'gross_margin_cost_90d':
|
||||
case 'gross_margin_base_180d':
|
||||
case 'gross_margin_cost_180d':
|
||||
case 'gross_margin_base_365d':
|
||||
case 'gross_margin_cost_365d':
|
||||
case 'gross_margin_base_total':
|
||||
case 'gross_margin_cost_total':
|
||||
case 'gross_margin_90d':
|
||||
@@ -3793,19 +4763,19 @@ function normalizeRow (row) {
|
||||
const color = String(row?.color_code || '').trim()
|
||||
const yaka = String(row?.yaka_kodu || '').trim()
|
||||
const market = String(row?.market_key || '').trim()
|
||||
return withPeriodScores(withProductMargins({
|
||||
return withPeriodScores(withGeneralMargins(withProductMargins({
|
||||
...row,
|
||||
row_key: `${product}|${color}|${yaka}|${market}`
|
||||
}))
|
||||
})))
|
||||
}
|
||||
|
||||
function withPeriodScores (row) {
|
||||
const next = { ...row }
|
||||
next.performance_score_90d = Number(next.performance_score_90d || productSalesPeriodScore(productPeriodMetricSource(next, '90d')))
|
||||
next.performance_score_180d = Number(next.performance_score_180d || productSalesPeriodScore(productPeriodMetricSource(next, '180d')))
|
||||
next.performance_score_365d = Number(next.performance_score_365d || productSalesPeriodScore(productPeriodMetricSource(next, '365d')))
|
||||
next.performance_score_total = Number(next.performance_score_total || productSalesPeriodScore(productPeriodMetricSource(next, 'total')))
|
||||
if (!Number(next.performance_score || 0)) next.performance_score = next.performance_score_90d
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_90d')) next.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(next, '90d'))
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_180d')) next.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(next, '180d'))
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_365d')) next.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(next, '365d'))
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_total')) next.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(next, 'total'))
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score')) next.performance_score = next.performance_score_90d
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -3926,47 +4896,47 @@ function productImageKey (row) {
|
||||
return `${product}|${color}|${yaka}`
|
||||
}
|
||||
|
||||
function productVariantMetricKey (row) {
|
||||
const product = String(row?.image_product_code || row?.product_code || '').trim()
|
||||
if (!product) return ''
|
||||
const color = String(row?.image_color_code || row?.color_code || '').trim()
|
||||
const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
|
||||
return `${product}|${color}|${yaka}`
|
||||
}
|
||||
|
||||
function displayMarketName (value) {
|
||||
const parts = String(value || '').split('|').map(part => part.trim()).filter(Boolean)
|
||||
return parts.length ? parts[parts.length - 1] : ''
|
||||
}
|
||||
|
||||
function normalizeUploadsPath (storagePath) {
|
||||
const raw = String(storagePath || '').trim()
|
||||
if (!raw) return ''
|
||||
const normalized = raw.replace(/\\/g, '/')
|
||||
const idx = normalized.toLowerCase().indexOf('/uploads/')
|
||||
if (idx >= 0) return normalized.slice(idx)
|
||||
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
|
||||
return ''
|
||||
function resolveProductImageUrl (item) {
|
||||
return resolveProductImageUrls(item)[0] || ''
|
||||
}
|
||||
|
||||
function resolveProductImageUrl (item) {
|
||||
if (!item || typeof item !== 'object') return ''
|
||||
|
||||
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||
if (contentURL.startsWith('/api/')) return contentURL
|
||||
if (contentURL.startsWith('/')) return `/api${contentURL}`
|
||||
|
||||
const imageId = Number(item.id || item.ID || 0)
|
||||
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
|
||||
function resolveProductImageUrls (item) {
|
||||
if (!item || typeof item !== 'object') return []
|
||||
const urls = []
|
||||
const addURL = value => {
|
||||
const url = String(value || '').trim()
|
||||
if (url && !urls.includes(url)) urls.push(url)
|
||||
}
|
||||
|
||||
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
|
||||
if (thumbURL) return thumbURL
|
||||
addURL(thumbURL)
|
||||
|
||||
const fullURL = String(item.full_url || item.fullUrl || '').trim()
|
||||
if (fullURL) return fullURL
|
||||
addURL(fullURL)
|
||||
|
||||
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
|
||||
if (uploadsPath) return uploadsPath
|
||||
|
||||
const fileName = String(item.file_name || item.FileName || '').trim()
|
||||
return fileName ? `/uploads/image/${fileName}` : ''
|
||||
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||
if (contentURL.startsWith('/api/')) addURL(contentURL)
|
||||
else if (contentURL.startsWith('/')) addURL(`/api${contentURL}`)
|
||||
return urls
|
||||
}
|
||||
|
||||
async function fetchProductImagesForRow (row) {
|
||||
if (!hasProductVariantImageKey(row)) return []
|
||||
const key = productImageKey(row)
|
||||
if (imageFailedKeys.value.has(key)) return []
|
||||
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) {
|
||||
return imageListByKey.value[key]
|
||||
}
|
||||
@@ -3993,8 +4963,13 @@ async function fetchProductImagesForRow (row) {
|
||||
timeout: 60000
|
||||
})
|
||||
const list = Array.isArray(resp?.data) ? resp.data : []
|
||||
const urls = list.map(resolveProductImageUrl).filter(Boolean)
|
||||
const urls = list.flatMap(resolveProductImageUrls).filter(Boolean)
|
||||
performanceStore.setImageCache(key, urls)
|
||||
if (urls.length && imageFailedKeys.value.has(key)) {
|
||||
const nextFailed = new Set(imageFailedKeys.value)
|
||||
nextFailed.delete(key)
|
||||
imageFailedKeys.value = nextFailed
|
||||
}
|
||||
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
|
||||
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
|
||||
return urls
|
||||
@@ -4009,17 +4984,62 @@ async function fetchProductImagesForRow (row) {
|
||||
function getCachedProductImageUrl (row) {
|
||||
if (!canShowProductImage(row)) return ''
|
||||
const key = productImageKey(row)
|
||||
return imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
|
||||
if (imageFailedKeys.value.has(key)) return ''
|
||||
const url = imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
|
||||
if (!url) queueVisibleProductImage(row)
|
||||
return url
|
||||
}
|
||||
|
||||
function markProductImageFailed (row) {
|
||||
function queueVisibleProductImage (row) {
|
||||
const key = productImageKey(row)
|
||||
if (!key || imageLoadingKeys.value.has(key) || imageFailedKeys.value.has(key)) return
|
||||
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) return
|
||||
imageLoadingKeys.value = new Set([...imageLoadingKeys.value, key])
|
||||
void fetchProductImagesForRow(row).finally(() => {
|
||||
const next = new Set(imageLoadingKeys.value)
|
||||
next.delete(key)
|
||||
imageLoadingKeys.value = next
|
||||
})
|
||||
}
|
||||
|
||||
function markProductImageFailed (row, failedUrl = '') {
|
||||
const key = productImageKey(row)
|
||||
if (!key) return
|
||||
const currentUrl = String(failedUrl || imageUrlByKey.value[key] || performanceStore.imageUrl(key) || '').trim()
|
||||
const list = imageListByKey.value[key] || performanceStore.imageList(key) || []
|
||||
const nextList = Array.isArray(list) ? list.filter(url => url && !sameImageUrl(url, currentUrl)) : []
|
||||
if (nextList.length) {
|
||||
performanceStore.setImageCache(key, nextList)
|
||||
imageListByKey.value = { ...imageListByKey.value, [key]: nextList }
|
||||
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: nextList[0] || '' }
|
||||
return
|
||||
}
|
||||
imageFailedKeys.value = new Set([...imageFailedKeys.value, key])
|
||||
performanceStore.setImageCache(key, [])
|
||||
imageListByKey.value = { ...imageListByKey.value, [key]: [] }
|
||||
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' }
|
||||
}
|
||||
|
||||
function sameImageUrl (left, right) {
|
||||
const a = String(left || '').trim()
|
||||
const b = String(right || '').trim()
|
||||
if (!a || !b) return false
|
||||
if (a === b) return true
|
||||
try {
|
||||
return new URL(a, window.location.origin).href === new URL(b, window.location.origin).href
|
||||
} catch {
|
||||
return a.endsWith(b) || b.endsWith(a)
|
||||
}
|
||||
}
|
||||
|
||||
function markDialogImageFailed (url) {
|
||||
const failedUrl = String(url || '').trim()
|
||||
if (!failedUrl) return
|
||||
imageDialogUrls.value = imageDialogUrls.value.filter(item => item && item !== failedUrl)
|
||||
if (imageSlide.value >= imageDialogUrls.value.length) imageSlide.value = 0
|
||||
if (imageDialogRow.value) markProductImageFailed(imageDialogRow.value, failedUrl)
|
||||
}
|
||||
|
||||
async function primeProductImages (sourceRows) {
|
||||
if (!isProductKpiTab.value) return
|
||||
imagePrimeActiveRequests += 1
|
||||
@@ -4031,7 +5051,7 @@ async function primeProductImages (sourceRows) {
|
||||
if (!canShowProductImage(row)) continue
|
||||
const key = productImageKey(row)
|
||||
const code = String(row?.image_product_code || row?.product_code || '').trim()
|
||||
if (!key || !code || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) continue
|
||||
if (!key || !code || imageFailedKeys.value.has(key) || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) continue
|
||||
seen.add(key)
|
||||
items.push({
|
||||
key,
|
||||
@@ -4048,8 +5068,9 @@ async function primeProductImages (sourceRows) {
|
||||
const nextUrls = { ...imageUrlByKey.value }
|
||||
for (const item of items) {
|
||||
const urls = batch?.lists?.[item.key] || performanceStore.imageList(item.key) || []
|
||||
if (!urls.length) imageFailedKeys.value = new Set([...imageFailedKeys.value, item.key])
|
||||
nextLists[item.key] = urls
|
||||
nextUrls[item.key] = batch?.urls?.[item.key] || urls[0] || ''
|
||||
nextUrls[item.key] = imageFailedKeys.value.has(item.key) ? '' : (batch?.urls?.[item.key] || urls[0] || '')
|
||||
}
|
||||
imageListByKey.value = nextLists
|
||||
imageUrlByKey.value = nextUrls
|
||||
@@ -4132,16 +5153,22 @@ function backendGroupedSupportedTab (tabKey = activeTab.value) {
|
||||
function activeExpandedGroupKeys () {
|
||||
const tabPrefix = `tab:${activeTab.value}|`
|
||||
const validFields = new Set(activeGroupLevels.value.map(level => level.key))
|
||||
const maxKeys = maxBackendExpandedKeysForTab(activeTab.value)
|
||||
const keys = Object.entries(expandedGroups.value)
|
||||
.filter(([, value]) => value === true)
|
||||
.map(([key]) => key)
|
||||
.filter(key => key.startsWith(tabPrefix))
|
||||
.filter(key => key.split('|').slice(1).every(part => validFields.has(String(part).split(':')[0])))
|
||||
.sort((a, b) => {
|
||||
const depthDiff = a.split('|').length - b.split('|').length
|
||||
if (depthDiff !== 0) return depthDiff
|
||||
return a.localeCompare(b, 'tr', { numeric: true })
|
||||
})
|
||||
const manualKey = lastManualExpandedGroupKey.value
|
||||
const out = []
|
||||
if (manualKey && keys.includes(manualKey)) out.push(manualKey)
|
||||
for (const key of keys) {
|
||||
if (out.length >= maxBulkExpandKeys) break
|
||||
if (out.length >= maxKeys) break
|
||||
if (key !== manualKey) out.push(key)
|
||||
}
|
||||
return out
|
||||
@@ -4164,32 +5191,114 @@ function scheduleLoadBackendGroupedRows () {
|
||||
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
|
||||
backendGroupedTimer = window.setTimeout(() => {
|
||||
void loadBackendGroupedRows()
|
||||
}, 120)
|
||||
}, 260)
|
||||
}
|
||||
|
||||
function cleanupTopScrollbarSync () {
|
||||
if (activeTableMiddleEl) {
|
||||
activeTableMiddleEl.removeEventListener('scroll', syncTopScrollbarFromTable)
|
||||
}
|
||||
if (topScrollbarRef.value) {
|
||||
topScrollbarRef.value.removeEventListener('scroll', syncTableFromTopScrollbar)
|
||||
}
|
||||
if (topScrollbarResizeObserver) {
|
||||
topScrollbarResizeObserver.disconnect()
|
||||
topScrollbarResizeObserver = null
|
||||
}
|
||||
activeTableMiddleEl = null
|
||||
}
|
||||
|
||||
function updateTopScrollbarSize () {
|
||||
const outer = topScrollbarRef.value
|
||||
const inner = topScrollbarInnerRef.value
|
||||
const tableMiddle = activeTableMiddleEl
|
||||
if (!outer || !inner || !tableMiddle) return
|
||||
inner.style.width = `${tableMiddle.scrollWidth}px`
|
||||
outer.scrollLeft = tableMiddle.scrollLeft
|
||||
outer.style.display = tableMiddle.scrollWidth > tableMiddle.clientWidth + 2 ? 'block' : 'none'
|
||||
}
|
||||
|
||||
function syncTopScrollbarFromTable () {
|
||||
if (topScrollbarSyncing || !topScrollbarRef.value || !activeTableMiddleEl) return
|
||||
topScrollbarSyncing = true
|
||||
topScrollbarRef.value.scrollLeft = activeTableMiddleEl.scrollLeft
|
||||
topScrollbarSyncing = false
|
||||
}
|
||||
|
||||
function syncTableFromTopScrollbar () {
|
||||
if (topScrollbarSyncing || !topScrollbarRef.value || !activeTableMiddleEl) return
|
||||
topScrollbarSyncing = true
|
||||
activeTableMiddleEl.scrollLeft = topScrollbarRef.value.scrollLeft
|
||||
topScrollbarSyncing = false
|
||||
}
|
||||
|
||||
function setupTopScrollbarSync () {
|
||||
if (typeof document === 'undefined') return
|
||||
void nextTick(() => {
|
||||
cleanupTopScrollbarSync()
|
||||
activeTableMiddleEl = document.querySelector('.product-performance-page .performance-table .q-table__middle')
|
||||
if (!activeTableMiddleEl || !topScrollbarRef.value) return
|
||||
activeTableMiddleEl.addEventListener('scroll', syncTopScrollbarFromTable, { passive: true })
|
||||
topScrollbarRef.value.addEventListener('scroll', syncTableFromTopScrollbar, { passive: true })
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
topScrollbarResizeObserver = new ResizeObserver(updateTopScrollbarSize)
|
||||
topScrollbarResizeObserver.observe(activeTableMiddleEl)
|
||||
const table = activeTableMiddleEl.querySelector('table')
|
||||
if (table) topScrollbarResizeObserver.observe(table)
|
||||
}
|
||||
updateTopScrollbarSize()
|
||||
window.setTimeout(updateTopScrollbarSize, 120)
|
||||
})
|
||||
}
|
||||
|
||||
function switchPerformanceTab (tabName) {
|
||||
if (!tabName || tabName === activeTab.value || pageBusy.value) return
|
||||
backendGroupedLoading.value = true
|
||||
if (!tabName || tabName === activeTab.value || loading.value) return
|
||||
activeTab.value = tabName
|
||||
}
|
||||
|
||||
async function loadBackendGroupedRows () {
|
||||
async function loadBackendGroupedRows (options = {}) {
|
||||
const tabKey = activeTab.value
|
||||
if (!backendGroupedSupportedTab(tabKey)) return
|
||||
const filterState = backendGroupedFilterState(tabKey)
|
||||
if (filterState.unsupported) {
|
||||
backendGroupedLoading.value = false
|
||||
return
|
||||
}
|
||||
if (tabKey === 'product_detail' && !ensureDetailMainGroupSelection()) {
|
||||
backendGroupedRows.value = {
|
||||
...backendGroupedRows.value,
|
||||
product_detail: []
|
||||
}
|
||||
backendGroupedLoading.value = false
|
||||
return
|
||||
}
|
||||
void ensureBackendGroupedFilterOptions(tabKey)
|
||||
pruneActiveExpandedGroups()
|
||||
backendGroupedLoading.value = true
|
||||
const filtersKey = JSON.stringify(filterState.filters)
|
||||
const expandThroughLevel = selectedExpandThroughLevel.value
|
||||
const sortState = tableSortState(tabKey)
|
||||
const requestKey = `${tabKey}|${expandThroughLevel}|${selectedDetailMainGroup.value || ''}|${filtersKey}|${sortState.sortBy}|${sortState.descending ? 1 : 0}`
|
||||
const params = {
|
||||
mode: tabKey,
|
||||
groupLevels: activeGroupLevels.value.map(level => level.key),
|
||||
expandedKeys: activeExpandedGroupKeys(),
|
||||
limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000
|
||||
expandThroughLevel,
|
||||
limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000,
|
||||
filters: filterState.filters,
|
||||
sortBy: sortState.sortBy,
|
||||
descending: sortState.descending
|
||||
}
|
||||
if (tabKey === 'product_detail') {
|
||||
params.urunAnaGrubu = selectedDetailMainGroup.value
|
||||
}
|
||||
try {
|
||||
const startedAt = performance.now()
|
||||
const rows = await performanceStore.fetchGroupedRows(params)
|
||||
const rows = await performanceStore.fetchGroupedRows(params, { force: options.force === true })
|
||||
const currentFilterState = backendGroupedFilterState(activeTab.value)
|
||||
const currentSortState = tableSortState(activeTab.value)
|
||||
const currentKey = `${activeTab.value}|${selectedExpandThroughLevel.value}|${selectedDetailMainGroup.value || ''}|${JSON.stringify(currentFilterState.filters)}|${currentSortState.sortBy}|${currentSortState.descending ? 1 : 0}`
|
||||
if (requestKey !== currentKey) return
|
||||
const normalizedRows = Array.isArray(rows) ? rows.map(row => normalizeGroupedDisplayRow(tabKey, row)) : []
|
||||
backendGroupedRows.value = {
|
||||
...backendGroupedRows.value,
|
||||
@@ -4210,17 +5319,7 @@ async function loadBackendGroupedRows () {
|
||||
function normalizeGroupedDisplayRow (tabKey, row) {
|
||||
const nextRow = { ...row }
|
||||
normalizeGroupedProductHierarchyValues(nextRow)
|
||||
if (tabKey === 'order_product_customers' || tabKey === 'order_market_details') {
|
||||
return withOrderPeriodScores(nextRow)
|
||||
}
|
||||
const next = withPeriodScores(nextRow)
|
||||
if (tabKey !== 'products' && tabKey !== 'product_detail' && tabKey !== 'idle') {
|
||||
next.customer_score_90d = Number(next.customer_score_90d || customerSalesPeriodScore(periodMetricSource(next, '90d')))
|
||||
next.customer_score_180d = Number(next.customer_score_180d || customerSalesPeriodScore(periodMetricSource(next, '180d')))
|
||||
next.customer_score_365d = Number(next.customer_score_365d || customerSalesPeriodScore(periodMetricSource(next, '365d')))
|
||||
next.customer_score_total = Number(next.customer_score_total || customerSalesPeriodScore(periodMetricSource(next, 'total')))
|
||||
}
|
||||
return next
|
||||
return withPeriodScores(withGeneralMargins(withProductMargins(nextRow)))
|
||||
}
|
||||
|
||||
function normalizeGroupedProductHierarchyValues (row) {
|
||||
@@ -4266,71 +5365,226 @@ async function timedProductPerformanceGet (label, url, config = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function productPerformanceExcelExportTableKey () {
|
||||
if (isProductKpiTab.value) return activeProductTableKey.value
|
||||
if (backendGroupedSupportedTab(activeTab.value)) return activeTab.value
|
||||
return 'products'
|
||||
}
|
||||
|
||||
function buildProductPerformanceExcelExportFilters () {
|
||||
const tableKey = productPerformanceExcelExportTableKey()
|
||||
const out = {}
|
||||
for (const col of filterColumnsForTableKey(tableKey) || []) {
|
||||
const name = String(col?.name || '').trim()
|
||||
if (!productPerformanceExcelExportFilterFields.has(name)) continue
|
||||
const selected = selectedColumnFilters(tableKey, name)
|
||||
if (selected.length) out[name] = selected
|
||||
}
|
||||
if (activeTab.value === 'product_detail') {
|
||||
const mainGroup = String(selectedDetailMainGroup.value || '').trim()
|
||||
if (mainGroup && !out.urun_ana_grubu?.includes(mainGroup)) {
|
||||
out.urun_ana_grubu = [...(out.urun_ana_grubu || []), mainGroup]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function productPerformanceExcelFilename (headers = {}) {
|
||||
const disposition = String(headers?.['content-disposition'] || headers?.['Content-Disposition'] || '').trim()
|
||||
const utfMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (utfMatch?.[1]) return decodeURIComponent(utfMatch[1].replaceAll('"', '').trim())
|
||||
const match = disposition.match(/filename="?([^";]+)"?/i)
|
||||
return match?.[1]?.trim() || `product_performance_color_yaka_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
}
|
||||
|
||||
function downloadProductPerformanceExcelBlob (blob, filename) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function exportProductPerformanceExcel () {
|
||||
if (excelExportLoading.value) return
|
||||
excelExportLoading.value = true
|
||||
try {
|
||||
const tableKey = productPerformanceExcelExportTableKey()
|
||||
const pagination = tablePagination[tableKey] || tablePagination.products || {}
|
||||
const response = await api.post('/pricing/product-performance/export-excel', {
|
||||
filters: buildProductPerformanceExcelExportFilters(),
|
||||
sort_by: pagination.sortBy || '',
|
||||
descending: pagination.descending !== false
|
||||
}, {
|
||||
responseType: 'blob',
|
||||
timeout: 180000
|
||||
})
|
||||
downloadProductPerformanceExcelBlob(response.data, productPerformanceExcelFilename(response.headers || {}))
|
||||
} catch (err) {
|
||||
const detail = await extractApiErrorDetail(err)
|
||||
Notify.create({ type: 'negative', message: detail || 'Excel çıktısı alınamadı' })
|
||||
} finally {
|
||||
excelExportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload () {
|
||||
loading.value = true
|
||||
startLoadingTimer('Rapor verileri hazırlanıyor...')
|
||||
try {
|
||||
const salesBreakdownRequests = salesBreakdownTabKeys.map(tabKey => {
|
||||
return timedProductPerformanceGet(`sales-breakdown:${tabKey}`, '/pricing/product-performance/sales-breakdown', {
|
||||
params: { limit: reportFetchLimit, mode: salesBreakdownModes[tabKey] },
|
||||
timeout: 60000
|
||||
})
|
||||
})
|
||||
const [summaryResp, listResp, generalResp, marketResp, countryResp, customerResp, ...salesBreakdownResp] = await Promise.all([
|
||||
timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 60000 }),
|
||||
timedProductPerformanceGet('product-kpi', '/pricing/product-performance', {
|
||||
params: {
|
||||
page: 1,
|
||||
limit: productKpiFetchLimit
|
||||
},
|
||||
timeout: 60000
|
||||
}),
|
||||
timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: reportFetchLimit }, timeout: 60000 }),
|
||||
timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: reportFetchLimit }, timeout: 60000 }),
|
||||
timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: reportFetchLimit }, timeout: 60000 }),
|
||||
timedProductPerformanceGet('customers', '/pricing/product-performance/customers', {
|
||||
params: { limit: reportFetchLimit, breakdown: customerBreakdown.value },
|
||||
timeout: 60000
|
||||
}),
|
||||
...salesBreakdownRequests
|
||||
rows.value = []
|
||||
detailMainGroupOptionRows.value = []
|
||||
generalRowsLoaded.value = false
|
||||
generalRows.value = []
|
||||
orderProductCustomersLoaded.value = false
|
||||
orderProductCustomerRows.value = []
|
||||
orderMarketDetailsLoaded.value = false
|
||||
orderMarketDetailRows.value = []
|
||||
orderAnalysisLoaded.value = false
|
||||
orderAnalysisRows.value = []
|
||||
orderMarketRows.value = []
|
||||
orderCustomerRows.value = []
|
||||
marketRowsLoaded.value = false
|
||||
marketRows.value = []
|
||||
countryRowsLoaded.value = false
|
||||
countryRows.value = []
|
||||
customerRowsLoaded.value = false
|
||||
customerRows.value = []
|
||||
for (const key of salesBreakdownTabKeys) salesBreakdownRows[key] = []
|
||||
|
||||
loadingStage.value = 'Ana KPI ve özet yükleniyor...'
|
||||
const [summaryResp] = await Promise.all([
|
||||
timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 180000 }),
|
||||
loadDetailMainGroupOptions(true)
|
||||
])
|
||||
summary.value = summaryResp?.data || {}
|
||||
const normalizedGeneralRows = (Array.isArray(generalResp?.data) ? generalResp.data : []).map(normalizeGeneralRow)
|
||||
const generalByVariant = new Map(normalizedGeneralRows.map(row => [performanceVariantKey(row), generalMetricFields(row)]))
|
||||
rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(row => {
|
||||
const generalMetrics = generalByVariant.get(performanceVariantKey(row)) || {}
|
||||
return normalizeRow({ ...row, ...generalMetrics })
|
||||
})
|
||||
generalRows.value = normalizedGeneralRows
|
||||
marketRows.value = Array.isArray(marketResp?.data) ? marketResp.data : []
|
||||
countryRows.value = (Array.isArray(countryResp?.data) ? countryResp.data : []).map(row => ({
|
||||
...row,
|
||||
country_key: `${row.country || '-'}|${row.customer_segment || '-'}|${row.market_key || '-'}`
|
||||
}))
|
||||
customerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(row => ({
|
||||
...row,
|
||||
customer_key: `${row.breakdown || '-'}|${row.market_key || '-'}|${row.country || '-'}|${row.customer_segment || '-'}|${row.customer_code || '-'}`
|
||||
}))
|
||||
salesBreakdownTabKeys.forEach((tabKey, index) => {
|
||||
salesBreakdownRows[tabKey] = (Array.isArray(salesBreakdownResp[index]?.data) ? salesBreakdownResp[index].data : []).map(normalizeSalesBreakdownRow)
|
||||
})
|
||||
if (activeTab.value === 'order_product_customers') {
|
||||
await loadOrderProductCustomers(false)
|
||||
}
|
||||
if (activeTab.value === 'order_market_details') {
|
||||
await loadOrderMarketDetails(false)
|
||||
}
|
||||
await loadBackendGroupedRows()
|
||||
loadingStage.value = 'Rapor ekranı hazırlanıyor...'
|
||||
await nextTick()
|
||||
ensureDetailMainGroupSelection()
|
||||
applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
stopLoadingTimer()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetailMainGroupOptions (force = false) {
|
||||
const optionRows = await performanceStore.fetchGroupedRows({
|
||||
mode: 'products',
|
||||
groupLevels: ['urun_ana_grubu'],
|
||||
expandThroughLevel: -1,
|
||||
limit: productKpiFetchLimit
|
||||
}, { force })
|
||||
detailMainGroupOptionRows.value = (Array.isArray(optionRows) ? optionRows : [])
|
||||
.filter(row => row?.__group)
|
||||
.map(row => {
|
||||
const normalized = normalizeGroupedDisplayRow('products', row)
|
||||
const value = productMainGroupValue(normalized) || String(normalized.group_value || normalized.label || '').trim()
|
||||
return {
|
||||
value,
|
||||
label: value || '-',
|
||||
stock_qty: Number(normalized.stock_qty || 0),
|
||||
sales_qty_90d: Number(normalized.sales_qty_90d || 0)
|
||||
}
|
||||
})
|
||||
.filter(option => option.value)
|
||||
}
|
||||
|
||||
function startLoadingTimer (stage = '') {
|
||||
loadingStage.value = stage
|
||||
loadingStartedAt.value = Date.now()
|
||||
loadingNow.value = Date.now()
|
||||
if (loadingTimer) window.clearInterval(loadingTimer)
|
||||
loadingTimer = window.setInterval(() => {
|
||||
loadingNow.value = Date.now()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopLoadingTimer () {
|
||||
if (loadingTimer) {
|
||||
window.clearInterval(loadingTimer)
|
||||
loadingTimer = null
|
||||
}
|
||||
loadingStartedAt.value = 0
|
||||
loadingNow.value = 0
|
||||
loadingStage.value = ''
|
||||
}
|
||||
|
||||
async function preloadAllReportTabs () {
|
||||
const tasks = [
|
||||
loadGeneralRows(false),
|
||||
loadOrderProductCustomers(false),
|
||||
loadOrderMarketDetails(false),
|
||||
loadMarketRows(false),
|
||||
loadCountryRows(false),
|
||||
loadCustomerRows(false),
|
||||
loadAllSalesBreakdownRows(false)
|
||||
]
|
||||
const results = await Promise.allSettled(tasks)
|
||||
const failed = results.filter(item => item.status === 'rejected')
|
||||
if (failed.length) {
|
||||
console.warn('[ProductPerformance][preload] partial failure', failed.map(item => item.reason?.message || item.reason))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllSalesBreakdownRows (showLoading = true) {
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
await Promise.all(salesBreakdownTabKeys.map(key => loadSalesBreakdownRows(key, false)))
|
||||
} finally {
|
||||
if (showLoading) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSalesBreakdownRows (tabKey, showLoading = true) {
|
||||
if (!salesBreakdownTabKeys.includes(tabKey)) return
|
||||
if (Array.isArray(salesBreakdownRows[tabKey]) && salesBreakdownRows[tabKey].length) return
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const mode = salesBreakdownModes[tabKey] || tabKey
|
||||
const resp = await timedProductPerformanceGet(`sales:${tabKey}`, '/pricing/product-performance/sales-breakdown', {
|
||||
params: { mode, limit: reportFetchLimit },
|
||||
timeout: 180000
|
||||
})
|
||||
salesBreakdownRows[tabKey] = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeSalesBreakdownRow)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Satış kırılım verisi alınamadı' })
|
||||
} finally {
|
||||
if (showLoading) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGeneralRows (showLoading = true) {
|
||||
if (generalRowsLoaded.value) return
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const resp = await timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: reportFetchLimit }, timeout: 180000 })
|
||||
const normalizedGeneralRows = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeGeneralRow)
|
||||
generalRows.value = normalizedGeneralRows
|
||||
generalRowsLoaded.value = true
|
||||
|
||||
const generalByVariant = new Map(normalizedGeneralRows.map(row => [performanceVariantKey(row), generalMetricFields(row)]))
|
||||
rows.value = rows.value.map(row => {
|
||||
const generalMetrics = generalByVariant.get(performanceVariantKey(row)) || {}
|
||||
return Object.keys(generalMetrics).length ? normalizeRow({ ...row, ...generalMetrics }) : row
|
||||
})
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Genel performans verisi alınamadı' })
|
||||
} finally {
|
||||
if (showLoading) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrderProductCustomers (showLoading = true) {
|
||||
if (orderProductCustomersLoaded.value) return
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: reportFetchLimit }, timeout: 90000 })
|
||||
const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: reportFetchLimit }, timeout: 180000 })
|
||||
orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
|
||||
orderProductCustomersLoaded.value = true
|
||||
} catch (err) {
|
||||
@@ -4341,9 +5595,10 @@ async function loadOrderProductCustomers (showLoading = true) {
|
||||
}
|
||||
|
||||
async function loadOrderMarketDetails (showLoading = true) {
|
||||
if (orderMarketDetailsLoaded.value) return
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: reportFetchLimit }, timeout: 90000 })
|
||||
const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: reportFetchLimit }, timeout: 180000 })
|
||||
orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow)
|
||||
orderMarketDetailsLoaded.value = true
|
||||
} catch (err) {
|
||||
@@ -4353,6 +5608,122 @@ async function loadOrderMarketDetails (showLoading = true) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMarketRows (showLoading = true) {
|
||||
if (marketRowsLoaded.value) return
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const resp = await timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: reportFetchLimit }, timeout: 180000 })
|
||||
marketRows.value = aggregateMarketRowsByDisplayName(Array.isArray(resp?.data) ? resp.data : [])
|
||||
marketRowsLoaded.value = true
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Piyasa performans verisi alınamadı' })
|
||||
} finally {
|
||||
if (showLoading) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateMarketRowsByDisplayName (sourceRows) {
|
||||
const grouped = new Map()
|
||||
for (const row of sourceRows || []) {
|
||||
const display = displayMarketName(row?.market_key) || String(row?.market_key || '').trim() || '-'
|
||||
const current = grouped.get(display) || {
|
||||
...row,
|
||||
row_key: `market|${display}`,
|
||||
market_key: display,
|
||||
product_count: 0,
|
||||
star_count: 0,
|
||||
stock_risk_count: 0,
|
||||
stock_qty: 0,
|
||||
stock_cost_value_usd: 0,
|
||||
risk_stock_cost_value_usd: 0,
|
||||
sales_qty_90d: 0,
|
||||
sales_usd_90d: 0,
|
||||
gross_profit_usd_90d: 0,
|
||||
_margin_weight: 0,
|
||||
_margin_total: 0,
|
||||
_stock_days_weight: 0,
|
||||
_stock_days_total: 0
|
||||
}
|
||||
current.product_count += Number(row?.product_count || 0)
|
||||
current.star_count += Number(row?.star_count || 0)
|
||||
current.stock_risk_count += Number(row?.stock_risk_count || 0)
|
||||
current.stock_qty += Number(row?.stock_qty || 0)
|
||||
current.stock_cost_value_usd += Number(row?.stock_cost_value_usd || 0)
|
||||
current.risk_stock_cost_value_usd += Number(row?.risk_stock_cost_value_usd || 0)
|
||||
current.sales_qty_90d += Number(row?.sales_qty_90d || 0)
|
||||
current.sales_usd_90d += Number(row?.sales_usd_90d || 0)
|
||||
current.gross_profit_usd_90d += Number(row?.gross_profit_usd_90d || 0)
|
||||
const marginWeight = Math.abs(Number(row?.sales_usd_90d || 0))
|
||||
current._margin_weight += marginWeight
|
||||
current._margin_total += Number(row?.avg_gross_margin_90d || 0) * marginWeight
|
||||
const stockDaysWeight = Math.abs(Number(row?.stock_qty || 0))
|
||||
current._stock_days_weight += stockDaysWeight
|
||||
current._stock_days_total += Number(row?.avg_stock_days_90d || 0) * stockDaysWeight
|
||||
grouped.set(display, current)
|
||||
}
|
||||
return Array.from(grouped.values())
|
||||
.map(row => {
|
||||
row.avg_gross_margin_90d = row._margin_weight > 0 ? row._margin_total / row._margin_weight : 0
|
||||
row.avg_stock_days_90d = row._stock_days_weight > 0 ? row._stock_days_total / row._stock_days_weight : 0
|
||||
delete row._margin_weight
|
||||
delete row._margin_total
|
||||
delete row._stock_days_weight
|
||||
delete row._stock_days_total
|
||||
return row
|
||||
})
|
||||
.sort((a, b) => Number(b.risk_stock_cost_value_usd || 0) - Number(a.risk_stock_cost_value_usd || 0) ||
|
||||
Number(b.stock_cost_value_usd || 0) - Number(a.stock_cost_value_usd || 0) ||
|
||||
Number(b.sales_usd_90d || 0) - Number(a.sales_usd_90d || 0))
|
||||
}
|
||||
|
||||
async function loadCountryRows (showLoading = true) {
|
||||
if (countryRowsLoaded.value) return
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const resp = await timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: reportFetchLimit }, timeout: 180000 })
|
||||
countryRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(row => ({
|
||||
...row,
|
||||
country_key: `${row.country || '-'}|${row.customer_segment || '-'}|${row.market_key || '-'}`
|
||||
}))
|
||||
countryRowsLoaded.value = true
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ülke performans verisi alınamadı' })
|
||||
} finally {
|
||||
if (showLoading) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomerRows (showLoading = true) {
|
||||
if (customerRowsLoaded.value) return
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const resp = await timedProductPerformanceGet('customers', '/pricing/product-performance/customers', {
|
||||
params: { limit: reportFetchLimit, breakdown: customerBreakdown.value },
|
||||
timeout: 180000
|
||||
})
|
||||
customerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(row => ({
|
||||
...row,
|
||||
customer_key: `${row.breakdown || '-'}|${row.market_key || '-'}|${row.country || '-'}|${row.customer_segment || '-'}|${row.customer_code || '-'}`
|
||||
}))
|
||||
customerRowsLoaded.value = true
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Müşteri performans verisi alınamadı' })
|
||||
} finally {
|
||||
if (showLoading) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureActiveTabData (showLoading = true) {
|
||||
const tab = activeTab.value
|
||||
if (tab === 'general') return loadGeneralRows(showLoading)
|
||||
if (tab === 'markets') return loadMarketRows(showLoading)
|
||||
if (tab === 'countries') return loadCountryRows(showLoading)
|
||||
if (tab === 'customers') return loadCustomerRows(showLoading)
|
||||
if (backendGroupedSupportedTab(tab)) {
|
||||
scheduleLoadBackendGroupedRows()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrderAnalysis (showLoading = true) {
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
@@ -4388,53 +5759,53 @@ function bucketColor (value) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, tab => {
|
||||
if (tab === 'order_product_customers' && !orderProductCustomersLoaded.value) {
|
||||
void loadOrderProductCustomers(true)
|
||||
}
|
||||
if (tab === 'order_market_details' && !orderMarketDetailsLoaded.value) {
|
||||
void loadOrderMarketDetails(true)
|
||||
}
|
||||
watch(activeTab, () => {
|
||||
void ensureActiveTabData(false).then(() => {
|
||||
if (backendGroupedSupportedTab(activeTab.value)) applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
|
||||
})
|
||||
setupTopScrollbarSync()
|
||||
})
|
||||
|
||||
watch(activeBackendGroupedSortSignature, () => {
|
||||
if (!backendGroupedSupportedTab(activeTab.value) || loading.value) return
|
||||
scheduleLoadBackendGroupedRows()
|
||||
})
|
||||
|
||||
watch(activeSelectedExpandLevelKeys, () => {
|
||||
scheduleLoadBackendGroupedRows()
|
||||
})
|
||||
watch(activeDisplayedTableRows, () => {
|
||||
setupTopScrollbarSync()
|
||||
}, { flush: 'post' })
|
||||
|
||||
watch(detailMainGroupOptions, options => {
|
||||
if (!options.length) {
|
||||
selectedDetailMainGroup.value = ''
|
||||
return
|
||||
}
|
||||
if (!selectedDetailMainGroup.value || !options.some(option => option.value === selectedDetailMainGroup.value)) {
|
||||
selectedDetailMainGroup.value = options[0].value
|
||||
}
|
||||
watch(detailMainGroupOptions, () => {
|
||||
ensureDetailMainGroupSelection()
|
||||
}, { immediate: true })
|
||||
|
||||
watch(selectedDetailMainGroup, () => {
|
||||
if (activeTab.value === 'product_detail') {
|
||||
expandedGroups.value = Object.fromEntries(
|
||||
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith('tab:product_detail|'))
|
||||
)
|
||||
backendGroupedRows.value = {
|
||||
...backendGroupedRows.value,
|
||||
product_detail: []
|
||||
}
|
||||
scheduleLoadBackendGroupedRows()
|
||||
void nextTick(() => {
|
||||
const level = selectedExpandThroughLevel.value
|
||||
applyActiveExpansionThroughLevel(level >= 0 ? autoExpandThroughLevel.value : -1)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(productTableRows, tableRows => {
|
||||
watch(activeDisplayedTableRows, tableRows => {
|
||||
if (!isProductKpiTab.value) return
|
||||
const sourceRows = activeTab.value === 'product_detail' ? displayDetailProductTableRows.value : tableRows
|
||||
const imageRows = sourceRows
|
||||
const imageRows = tableRows
|
||||
.filter(row => !row.__group || groupShowsImage(row))
|
||||
.slice(0, 240)
|
||||
void primeProductImages(imageRows)
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(reload)
|
||||
onMounted(() => {
|
||||
void reload()
|
||||
setupTopScrollbarSync()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupTopScrollbarSync()
|
||||
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
|
||||
stopLoadingTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -4452,7 +5823,7 @@ onMounted(reload)
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(246, 247, 249, 0.82);
|
||||
background: #f6f7f9;
|
||||
backdrop-filter: blur(2px);
|
||||
pointer-events: all;
|
||||
}
|
||||
@@ -4463,6 +5834,12 @@ onMounted(reload)
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-busy-time {
|
||||
color: #5f6b7a;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.performance-top-bar {
|
||||
min-height: 34px;
|
||||
}
|
||||
@@ -4634,13 +6011,27 @@ onMounted(reload)
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table__middle) {
|
||||
max-height: calc(100vh - 220px);
|
||||
transform: rotateX(180deg);
|
||||
.performance-top-scrollbar {
|
||||
display: none;
|
||||
height: 14px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #d5dce5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table__middle > .q-table) {
|
||||
transform: rotateX(180deg);
|
||||
.performance-top-scrollbar-inner {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table__middle) {
|
||||
max-height: calc(100vh - 220px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table__middle::-webkit-scrollbar:horizontal) {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table__bottom) {
|
||||
@@ -4747,117 +6138,174 @@ onMounted(reload)
|
||||
border-left: 1px solid #d5dce5;
|
||||
}
|
||||
|
||||
.performance-table :deep(th) {
|
||||
.performance-table :deep(.q-table thead),
|
||||
.performance-table :deep(.q-table thead tr),
|
||||
.performance-table :deep(.q-table th) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
z-index: 20;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.performance-table :deep(th) {
|
||||
border-top: 1px solid #d5dce5;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table) {
|
||||
min-width: 3000px;
|
||||
min-width: 3170px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(-n+10)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(-n+10)) {
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(-n+10)) {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(-n+10)) {
|
||||
z-index: 4;
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)) {
|
||||
z-index: 30;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) {
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(1)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(1)) {
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(1)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(1)) {
|
||||
left: 0;
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
max-width: 72px;
|
||||
width: 190px;
|
||||
min-width: 190px;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(2)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(2)) {
|
||||
left: 72px;
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(2)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(2)) {
|
||||
left: 190px;
|
||||
width: 140px;
|
||||
min-width: 140px;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(3)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(3)) {
|
||||
left: 212px;
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(3)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(3)) {
|
||||
left: 330px;
|
||||
width: 110px;
|
||||
min-width: 110px;
|
||||
max-width: 110px;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(4)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(4)) {
|
||||
left: 322px;
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(4)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(4)) {
|
||||
left: 440px;
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(5)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(5)) {
|
||||
left: 472px;
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(5)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(5)) {
|
||||
left: 590px;
|
||||
width: 170px;
|
||||
min-width: 170px;
|
||||
max-width: 170px;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(6)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(6)) {
|
||||
left: 642px;
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(6)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(6)) {
|
||||
left: 760px;
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(7)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(7)) {
|
||||
left: 792px;
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(7)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(7)) {
|
||||
left: 910px;
|
||||
width: 130px;
|
||||
min-width: 130px;
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(8)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(8)) {
|
||||
left: 922px;
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(8)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(8)) {
|
||||
left: 1040px;
|
||||
width: 90px;
|
||||
min-width: 90px;
|
||||
max-width: 90px;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(9)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(9)) {
|
||||
left: 1012px;
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(9)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(9)) {
|
||||
left: 1130px;
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(10)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(10)) {
|
||||
left: 1092px;
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(10)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(10)) {
|
||||
left: 1210px;
|
||||
width: 130px;
|
||||
min-width: 130px;
|
||||
max-width: 130px;
|
||||
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
|
||||
}
|
||||
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table) {
|
||||
min-width: 2450px;
|
||||
}
|
||||
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(-n+4)),
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(-n+4)) {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(-n+4)) {
|
||||
z-index: 30;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+4)) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(1)),
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(1)) {
|
||||
left: 0;
|
||||
width: 86px;
|
||||
min-width: 86px;
|
||||
max-width: 86px;
|
||||
}
|
||||
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(2)),
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(2)) {
|
||||
left: 86px;
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(3)),
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(3)) {
|
||||
left: 236px;
|
||||
width: 112px;
|
||||
min-width: 112px;
|
||||
max-width: 112px;
|
||||
}
|
||||
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(4)),
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(4)) {
|
||||
left: 348px;
|
||||
width: 132px;
|
||||
min-width: 132px;
|
||||
max-width: 132px;
|
||||
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
|
||||
}
|
||||
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table) {
|
||||
min-width: 2500px;
|
||||
min-width: 2620px;
|
||||
}
|
||||
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(n+8)) {
|
||||
@@ -4869,7 +6317,7 @@ onMounted(reload)
|
||||
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(n+8)) {
|
||||
left: auto;
|
||||
z-index: 1;
|
||||
z-index: 20;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -4880,7 +6328,7 @@ onMounted(reload)
|
||||
}
|
||||
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(-n+7)) {
|
||||
z-index: 4;
|
||||
z-index: 30;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
@@ -4891,14 +6339,14 @@ onMounted(reload)
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(1)),
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(1)) {
|
||||
left: 0;
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
max-width: 72px;
|
||||
width: 190px;
|
||||
min-width: 190px;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(2)),
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(2)) {
|
||||
left: 72px;
|
||||
left: 190px;
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
max-width: 150px;
|
||||
@@ -4906,7 +6354,7 @@ onMounted(reload)
|
||||
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(3)),
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(3)) {
|
||||
left: 222px;
|
||||
left: 340px;
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
max-width: 150px;
|
||||
@@ -4914,7 +6362,7 @@ onMounted(reload)
|
||||
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(4)),
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(4)) {
|
||||
left: 372px;
|
||||
left: 490px;
|
||||
width: 130px;
|
||||
min-width: 130px;
|
||||
max-width: 130px;
|
||||
@@ -4922,7 +6370,7 @@ onMounted(reload)
|
||||
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(5)),
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(5)) {
|
||||
left: 502px;
|
||||
left: 620px;
|
||||
width: 170px;
|
||||
min-width: 170px;
|
||||
max-width: 170px;
|
||||
@@ -4930,7 +6378,7 @@ onMounted(reload)
|
||||
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(6)),
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(6)) {
|
||||
left: 672px;
|
||||
left: 790px;
|
||||
width: 170px;
|
||||
min-width: 170px;
|
||||
max-width: 190px;
|
||||
@@ -4938,7 +6386,7 @@ onMounted(reload)
|
||||
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(7)),
|
||||
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(7)) {
|
||||
left: 842px;
|
||||
left: 960px;
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
max-width: 120px;
|
||||
@@ -4972,10 +6420,14 @@ onMounted(reload)
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.product-breakdown-table :deep(.q-table th:nth-child(n+11):not(.text-right)),
|
||||
.product-breakdown-table :deep(.q-table td:nth-child(n+11):not(.text-right)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(n+11):not(.text-right)),
|
||||
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(n+11):not(.text-right)),
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(n+5):not(.text-right)),
|
||||
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(n+5):not(.text-right)),
|
||||
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(n+4):not(.text-right)),
|
||||
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(n+4):not(.text-right)),
|
||||
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(n+5):not(.text-right)),
|
||||
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(n+5):not(.text-right)),
|
||||
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(n+6):not(.text-right)),
|
||||
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(n+6):not(.text-right)),
|
||||
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(n+7):not(.text-right)),
|
||||
@@ -5055,6 +6507,8 @@ onMounted(reload)
|
||||
|
||||
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)),
|
||||
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(-n+3)),
|
||||
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(-n+4)),
|
||||
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(-n+4)),
|
||||
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)),
|
||||
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(-n+5)),
|
||||
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)),
|
||||
@@ -5074,6 +6528,7 @@ onMounted(reload)
|
||||
}
|
||||
|
||||
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)),
|
||||
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(-n+4)),
|
||||
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)),
|
||||
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)),
|
||||
.sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)),
|
||||
@@ -5081,7 +6536,7 @@ onMounted(reload)
|
||||
.sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(-n+9)),
|
||||
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)),
|
||||
.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(-n+11)) {
|
||||
z-index: 4;
|
||||
z-index: 30;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
@@ -5091,6 +6546,8 @@ onMounted(reload)
|
||||
|
||||
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(3)),
|
||||
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(3)),
|
||||
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(4)),
|
||||
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(4)),
|
||||
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(5)),
|
||||
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(5)),
|
||||
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(6)),
|
||||
@@ -5277,6 +6734,28 @@ onMounted(reload)
|
||||
border-color: #c4c9d1;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody tr.group-row.group-row-field-color_yaka td) {
|
||||
background: #f7e3bf;
|
||||
color: #1f2937;
|
||||
border-top: 2px solid #c99032;
|
||||
border-bottom: 2px solid #c99032;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody tr.group-row.group-row-field-color_yaka td:first-child) {
|
||||
border-left: 6px solid #b76a00;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody tr.group-row.group-row-field-market_key td) {
|
||||
background: #d9e8ff;
|
||||
color: #132238;
|
||||
border-top: 2px solid #7da9e6;
|
||||
border-bottom: 2px solid #7da9e6;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody tr.group-row.group-row-field-market_key td:first-child) {
|
||||
border-left: 6px solid #2563b8;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody tr.group-row td span),
|
||||
.performance-table :deep(.q-table tbody tr.group-row .group-title),
|
||||
.performance-table :deep(.q-table tbody tr.group-row .q-btn),
|
||||
@@ -5312,32 +6791,38 @@ onMounted(reload)
|
||||
}
|
||||
|
||||
.product-image-cell {
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
width: 190px;
|
||||
min-width: 190px;
|
||||
max-width: 190px;
|
||||
padding: 10px !important;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.product-thumb-button {
|
||||
width: 54px;
|
||||
height: 72px;
|
||||
width: 162px;
|
||||
height: 216px;
|
||||
border: 1px solid #d9dde3;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-thumb,
|
||||
.product-thumb-placeholder {
|
||||
width: 46px;
|
||||
height: 64px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.product-thumb {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.product-thumb-placeholder {
|
||||
|
||||
@@ -423,7 +423,6 @@ const productImageListByCode = ref({})
|
||||
const productImageListLoading = ref({})
|
||||
const productImageFallbackByKey = ref({})
|
||||
const productImageContentLoading = ref({})
|
||||
const productImageBlobUrls = ref([])
|
||||
const productImageListBlockedUntil = ref(0)
|
||||
const productCardDialog = ref(false)
|
||||
const productCardData = ref({})
|
||||
@@ -637,14 +636,9 @@ function resolveProductImageUrl(item) {
|
||||
}
|
||||
|
||||
let contentUrl = ''
|
||||
const imageId = Number(item.id || item.ID || 0)
|
||||
if (Number.isFinite(imageId) && imageId > 0) {
|
||||
contentUrl = `/api/product-images/${imageId}/content`
|
||||
} else {
|
||||
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||
if (contentURL.startsWith('/api/')) contentUrl = contentURL
|
||||
else if (contentURL.startsWith('/')) contentUrl = `/api${contentURL}`
|
||||
}
|
||||
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||
if (contentURL.startsWith('/api/')) contentUrl = contentURL
|
||||
else if (contentURL.startsWith('/')) contentUrl = `/api${contentURL}`
|
||||
|
||||
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
|
||||
let publicUrl = ''
|
||||
@@ -708,23 +702,12 @@ function clearGalleryQueryIndex() {
|
||||
async function resolveProductImageUrlForCarousel(item) {
|
||||
const resolved = resolveProductImageUrl(item)
|
||||
const contentUrl = String(resolved.contentUrl || '').trim()
|
||||
if (contentUrl) {
|
||||
try {
|
||||
const blobRes = await api.get(contentUrl, { baseURL: '', responseType: 'blob' })
|
||||
const blob = blobRes?.data
|
||||
if (blob instanceof Blob) {
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
productImageBlobUrls.value.push(objectUrl)
|
||||
return objectUrl
|
||||
}
|
||||
} catch {
|
||||
// fall through to public url
|
||||
}
|
||||
}
|
||||
const fullUrl = String(resolved.fullUrl || '').trim()
|
||||
if (fullUrl) return fullUrl
|
||||
const publicUrl = String(resolved.publicUrl || '').trim()
|
||||
return String(publicUrl || fullUrl || contentUrl || '').trim()
|
||||
if (contentUrl) return contentUrl
|
||||
|
||||
const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim()
|
||||
if (directUrl) return directUrl
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
|
||||
@@ -774,9 +757,9 @@ async function flushProductImageBatch() {
|
||||
const list = Array.isArray(item.images) ? item.images : []
|
||||
const first = list[0] || null
|
||||
const resolved = resolveProductImageUrl(first)
|
||||
const url = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
|
||||
const url = resolved.contentUrl || resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
|
||||
productImageCache.value[key] = String(url || '').trim()
|
||||
productImageFallbackByKey.value[key] = resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
|
||||
productImageFallbackByKey.value[key] = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
|
||||
if (queued) productImageListByCode.value[queued.listKey] = list
|
||||
delete productImageLoading.value[key]
|
||||
}
|
||||
@@ -865,11 +848,11 @@ async function ensureProductImage(code, color, secondColor = '', dim1Id = '', di
|
||||
|
||||
productImageCache.value[key] = String(
|
||||
preferredCardUrl ||
|
||||
primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || primaryResolved.contentUrl ||
|
||||
secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || secondaryResolved.contentUrl ||
|
||||
primaryResolved.contentUrl || primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl ||
|
||||
secondaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl ||
|
||||
''
|
||||
).trim()
|
||||
productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.contentUrl || ''
|
||||
productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || ''
|
||||
} catch (err) {
|
||||
console.warn('[ProductStockByAttributes] product image fetch failed', { code, color, err })
|
||||
productImageCache.value[key] = ''
|
||||
@@ -1581,10 +1564,6 @@ onUnmounted(() => {
|
||||
clearTimeout(filterOptionsDebounceTimer)
|
||||
filterOptionsDebounceTimer = null
|
||||
}
|
||||
for (const url of productImageBlobUrls.value) {
|
||||
try { URL.revokeObjectURL(url) } catch {}
|
||||
}
|
||||
productImageBlobUrls.value = []
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
@@ -390,7 +390,6 @@ const productImageListByCode = ref({})
|
||||
const productImageListLoading = ref({})
|
||||
const productImageFallbackByKey = ref({})
|
||||
const productImageContentLoading = ref({})
|
||||
const productImageBlobUrls = ref([])
|
||||
const productImageListBlockedUntil = ref(0)
|
||||
const productCardDialog = ref(false)
|
||||
const productCardData = ref({})
|
||||
@@ -618,14 +617,9 @@ function resolveProductImageUrl(item) {
|
||||
}
|
||||
|
||||
let contentUrl = ''
|
||||
const imageId = Number(item.id || item.ID || 0)
|
||||
if (Number.isFinite(imageId) && imageId > 0) {
|
||||
contentUrl = `/api/product-images/${imageId}/content`
|
||||
} else {
|
||||
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||
if (contentURL.startsWith('/api/')) contentUrl = contentURL
|
||||
else if (contentURL.startsWith('/')) contentUrl = `/api${contentURL}`
|
||||
}
|
||||
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||
if (contentURL.startsWith('/api/')) contentUrl = contentURL
|
||||
else if (contentURL.startsWith('/')) contentUrl = `/api${contentURL}`
|
||||
|
||||
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
|
||||
let publicUrl = ''
|
||||
@@ -687,23 +681,12 @@ function clearGalleryQueryIndex() {
|
||||
async function resolveProductImageUrlForCarousel(item) {
|
||||
const resolved = resolveProductImageUrl(item)
|
||||
const contentUrl = String(resolved.contentUrl || '').trim()
|
||||
if (contentUrl) {
|
||||
try {
|
||||
const blobRes = await api.get(contentUrl, { baseURL: '', responseType: 'blob' })
|
||||
const blob = blobRes?.data
|
||||
if (blob instanceof Blob) {
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
productImageBlobUrls.value.push(objectUrl)
|
||||
return objectUrl
|
||||
}
|
||||
} catch {
|
||||
// fall through to public url
|
||||
}
|
||||
}
|
||||
const fullUrl = String(resolved.fullUrl || '').trim()
|
||||
if (fullUrl) return fullUrl
|
||||
const publicUrl = String(resolved.publicUrl || '').trim()
|
||||
return String(publicUrl || fullUrl || contentUrl || '').trim()
|
||||
if (contentUrl) return contentUrl
|
||||
|
||||
const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim()
|
||||
if (directUrl) return directUrl
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
|
||||
@@ -753,9 +736,9 @@ async function flushProductImageBatch() {
|
||||
const list = Array.isArray(item.images) ? item.images : []
|
||||
const first = list[0] || null
|
||||
const resolved = resolveProductImageUrl(first)
|
||||
const url = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
|
||||
const url = resolved.contentUrl || resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
|
||||
productImageCache.value[key] = String(url || '').trim()
|
||||
productImageFallbackByKey.value[key] = resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
|
||||
productImageFallbackByKey.value[key] = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
|
||||
if (queued) productImageListByCode.value[queued.listKey] = list
|
||||
delete productImageLoading.value[key]
|
||||
}
|
||||
@@ -841,11 +824,11 @@ async function ensureProductImage(code, color, secondColor = '', dim1Id = '', di
|
||||
const secondaryResolved = resolveProductImageUrl(secondaryItem)
|
||||
productImageCache.value[key] = String(
|
||||
preferredCardUrl ||
|
||||
primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || primaryResolved.contentUrl ||
|
||||
secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || secondaryResolved.contentUrl ||
|
||||
primaryResolved.contentUrl || primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl ||
|
||||
secondaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl ||
|
||||
''
|
||||
).trim()
|
||||
productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.contentUrl || ''
|
||||
productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || ''
|
||||
} catch (err) {
|
||||
console.warn('[ProductStockQuery] product image fetch failed', { code, color, err })
|
||||
productImageCache.value[key] = ''
|
||||
@@ -1379,10 +1362,6 @@ function resetForm() {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', onFullscreenMouseMove)
|
||||
window.removeEventListener('mouseup', onFullscreenMouseUp)
|
||||
for (const url of productImageBlobUrls.value) {
|
||||
try { URL.revokeObjectURL(url) } catch {}
|
||||
}
|
||||
productImageBlobUrls.value = []
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
:loading="loading"
|
||||
@click="fetchRows"
|
||||
/>
|
||||
<q-btn
|
||||
label="Excel'e Aktar"
|
||||
icon="download"
|
||||
color="primary"
|
||||
outline
|
||||
:disable="loading || rows.length === 0"
|
||||
@click="exportVisibleRows"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +149,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePermission } from 'src/composables/usePermission'
|
||||
import { get, extractApiErrorDetail } from 'src/services/api'
|
||||
@@ -168,6 +176,10 @@ const dateColumns = new Set(['Tarihi', 'dteKayitTarihi', 'dteGuncellemeTarihi',
|
||||
const columns = [
|
||||
{ name: 'open', label: '', field: 'open', align: 'center', sortable: false, style: 'width:3%', headerStyle: 'width:3%' },
|
||||
{ name: 'UretimSekli', label: 'Uretim Sekli', field: 'UretimSekli', align: 'left', sortable: true, style: 'width:9%', headerStyle: 'width:9%' },
|
||||
{ name: 'nUrtSiparisNo', label: 'Uretim Siparis No', field: 'nUrtSiparisNo', align: 'left', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
|
||||
{ name: 'ceketDepoGiris', label: 'C-Ceket Depo Giris', field: 'ceketDepoGiris', align: 'right', sortable: true, format: val => formatMoney(val), style: 'width:6%', headerStyle: 'width:6%' },
|
||||
{ name: 'pantolonDepoGiris', label: 'P-Pantolon Depo Giris', field: 'pantolonDepoGiris', align: 'right', sortable: true, format: val => formatMoney(val), style: 'width:6%', headerStyle: 'width:6%' },
|
||||
{ name: 'yelekDepoGiris', label: 'Y-Yelek Depo Giris', field: 'yelekDepoGiris', align: 'right', sortable: true, format: val => formatMoney(val), style: 'width:6%', headerStyle: 'width:6%' },
|
||||
{ name: 'nOnMLNo', label: 'nOnMLNo', field: 'nOnMLNo', align: 'left', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
|
||||
{ name: 'UrunKodu', label: 'UrunKodu', field: 'UrunKodu', align: 'left', sortable: true, style: 'width:7%', headerStyle: 'width:7%' },
|
||||
{ name: 'UrunAdi', label: 'UrunAdi', field: 'UrunAdi', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
|
||||
@@ -186,6 +198,30 @@ const columns = [
|
||||
]
|
||||
|
||||
const columnFilters = reactive({})
|
||||
const LIST_STATE_STORAGE_KEY = 'bssapp:production-product-costing:has-cost-list-state:v1'
|
||||
|
||||
function persistListState () {
|
||||
try {
|
||||
sessionStorage.setItem(LIST_STATE_STORAGE_KEY, JSON.stringify({
|
||||
filters: { search: String(filters.search || '') },
|
||||
columnFilters: JSON.parse(JSON.stringify(columnFilters)),
|
||||
pagination: { ...tablePagination.value }
|
||||
}))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function restoreListState () {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(LIST_STATE_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const saved = JSON.parse(raw)
|
||||
filters.search = String(saved?.filters?.search || '')
|
||||
Object.assign(columnFilters, saved?.columnFilters && typeof saved.columnFilters === 'object' ? saved.columnFilters : {})
|
||||
if (saved?.pagination && typeof saved.pagination === 'object') {
|
||||
tablePagination.value = { ...tablePagination.value, ...saved.pagination }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getColumnFilter (name) {
|
||||
if (!columnFilters[name]) {
|
||||
@@ -293,9 +329,11 @@ function clearAllColumnFilters () {
|
||||
}
|
||||
|
||||
let searchTimer = null
|
||||
let filterWatchEnabled = false
|
||||
watch(
|
||||
() => filters.search,
|
||||
() => {
|
||||
if (!filterWatchEnabled) return
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
fetchRows()
|
||||
@@ -348,19 +386,66 @@ function clearFilters () {
|
||||
fetchRows()
|
||||
}
|
||||
|
||||
function escapeExcelCsvCell (value) {
|
||||
let text = String(value ?? '')
|
||||
// Prevent spreadsheet applications from evaluating data as a formula.
|
||||
if (/^[=+\-@]/.test(text)) text = `'${text}`
|
||||
return `"${text.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
const excelNumericColumns = new Set(['ceketDepoGiris', 'pantolonDepoGiris', 'yelekDepoGiris', 'lTutarTL', 'lTutarUSD', 'lTutarEURO'])
|
||||
|
||||
function formatExcelCsvCell (col, rawValue, displayValue) {
|
||||
if (excelNumericColumns.has(col?.name)) {
|
||||
const numericValue = Number(rawValue)
|
||||
if (Number.isFinite(numericValue)) return String(numericValue).replace('.', ',')
|
||||
}
|
||||
return escapeExcelCsvCell(displayValue)
|
||||
}
|
||||
|
||||
function exportVisibleRows () {
|
||||
const visibleRows = Array.isArray(rows.value) ? rows.value : []
|
||||
if (visibleRows.length === 0) return
|
||||
|
||||
const exportColumns = columns.filter(col => col.name !== 'open')
|
||||
const csvLines = [
|
||||
exportColumns.map(col => escapeExcelCsvCell(col.label)).join(';'),
|
||||
...visibleRows.map(row => exportColumns.map(col => {
|
||||
const rawValue = typeof col.field === 'function' ? col.field(row) : row?.[col.field]
|
||||
const displayValue = typeof col.format === 'function' ? col.format(rawValue, row) : rawValue
|
||||
return formatExcelCsvCell(col, rawValue, displayValue)
|
||||
}).join(';'))
|
||||
]
|
||||
|
||||
const blob = new Blob([`\uFEFF${csvLines.join('\r\n')}`], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `mevcut_maliyetli_urunler_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function openRow (row) {
|
||||
const urunKodu = String(row?.UrunKodu || '').trim()
|
||||
if (!urunKodu) return
|
||||
|
||||
persistListState()
|
||||
|
||||
router.push({
|
||||
name: 'production-product-costing-has-cost-history',
|
||||
query: { urun_kodu: urunKodu }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
if (!canReadOrder.value) return
|
||||
fetchRows()
|
||||
restoreListState()
|
||||
await nextTick()
|
||||
filterWatchEnabled = true
|
||||
await fetchRows()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -457,7 +457,7 @@
|
||||
|
||||
<div v-else class="column q-gutter-md pcd-content-body">
|
||||
<div
|
||||
v-for="(grp, gi) in detailGroups"
|
||||
v-for="(grp, gi) in detailGroupsWithTotals"
|
||||
:key="groupKey(grp, gi)"
|
||||
class="pcd-group-card"
|
||||
>
|
||||
@@ -469,7 +469,11 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="sub-right pcd-sub-right-clickable" @click="toggleGroup(grp, gi)">
|
||||
Grup Toplami TRY: {{ formatBarMoney(resolveGroupTRYTutar(grp)) }} | USD: {{ formatBarMoney(resolveGroupUSDTutar(grp)) }}
|
||||
<span class="pcd-group-total-text" data-no-runtime-i18n="true">
|
||||
Grup Toplamı TRY: {{ formatBarMoney(grp.tryTotal) }}
|
||||
<span class="pcd-group-total-divider">|</span>
|
||||
USD: {{ formatBarMoney(grp.usdTotal) }}
|
||||
</span>
|
||||
<q-icon
|
||||
:name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'"
|
||||
size="18px"
|
||||
@@ -1557,8 +1561,14 @@ function ensureBeforeUnloadGuard (enabled) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
function shouldIncludeRowInCostingTotal (row) {
|
||||
// The explicit "Maliyete Dahil" selection is authoritative for the top
|
||||
// header and the amount persisted to spUrtOnMLMas, including CM rows.
|
||||
return normalizeBooleanFlag(row?.maliyeteDahil ?? row?.maliyete_dahil ?? row?.Maliyete_dahil)
|
||||
}
|
||||
|
||||
const toolbarSummary = computed(() => flatDetailRows.value.reduce((acc, row) => {
|
||||
if (!row?.maliyeteDahil) return acc
|
||||
if (!shouldIncludeRowInCostingTotal(row)) return acc
|
||||
acc.tryTotal += resolveRowTRYTutar(row)
|
||||
acc.usdTotal += resolveRowUSDTutar(row)
|
||||
acc.eurTotal += resolveRowEURTutar(row)
|
||||
@@ -2694,7 +2704,9 @@ function normalizeDetailRows (items, groupName = '') {
|
||||
// Use fiyat_girilen which is the unit price in original currency.
|
||||
inputPrice: x?.inputPrice ?? normalizeInputPrice(x?.fiyat_girilen),
|
||||
inputPricePrBr: originalCurrency,
|
||||
maliyeteDahil: x?.maliyeteDahil ?? normalizeBooleanFlag(x?.maliyete_dahil ?? x?.Maliyete_dahil),
|
||||
maliyeteDahil: normalizeBooleanFlag(x?.maliyeteDahil ?? x?.maliyete_dahil ?? x?.Maliyete_dahil),
|
||||
__lastValidTRYAmount: parseMoneyInput(x?.lTutar ?? x?.lTutarTL),
|
||||
__lastValidUSDAmount: parseMoneyInput(x?.usdTutar ?? x?.lTutarUSD),
|
||||
cmPriceTypeId: normalizeCMPriceTypeId(x?.cmPriceTypeId ?? x?.cm_price_type_id, groupName || x?.sAciklama3),
|
||||
draftChanged: Boolean(x?.draftChanged),
|
||||
priceUpdateState: String(x?.priceUpdateState || '').trim()
|
||||
@@ -2796,6 +2808,13 @@ function recalculateDetailRow (row, options = {}) {
|
||||
row.lTutarGBP = row.gbpTutar
|
||||
row.lTutarTL = row.lTutar
|
||||
|
||||
if (row.lTutar !== 0 || options.markChanged) {
|
||||
row.__lastValidTRYAmount = row.lTutar
|
||||
}
|
||||
if (row.usdTutar !== 0 || options.markChanged) {
|
||||
row.__lastValidUSDAmount = row.usdTutar
|
||||
}
|
||||
|
||||
if (!options.preserveInputs) {
|
||||
row.miktarInput = normalizeQuantityInput(quantity)
|
||||
row.inputPrice = normalizeInputPrice(inputPrice)
|
||||
@@ -2817,58 +2836,124 @@ function recalculateDetailRow (row, options = {}) {
|
||||
}
|
||||
|
||||
function recalculateAllDetailRows () {
|
||||
detailGroups.value = detailGroups.value.map(grp => ({
|
||||
...grp,
|
||||
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => recalculateDetailRow({ ...row }, { preserveInputs: true }))
|
||||
}))
|
||||
// Preserve row identity. QTable editors and the computed group totals must
|
||||
// observe the exact same reactive row objects. Replacing rows with
|
||||
// `{ ...row }` after the async exchange-rate request left QTable editing an
|
||||
// older object while totals watched the replacement object.
|
||||
detailGroups.value.forEach(grp => {
|
||||
const items = Array.isArray(grp?.items) ? grp.items : []
|
||||
items.forEach(row => {
|
||||
recalculateDetailRow(row, { preserveInputs: true })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolveRowTRYTutar (row) {
|
||||
const tryAmount = Number(row?.lTutar || 0)
|
||||
return Number.isFinite(tryAmount) ? tryAmount : 0
|
||||
const quantity = resolveNumericRowQuantity(row)
|
||||
const inputPrice = resolveNumericRowInputPrice(row)
|
||||
const inputCurrency = resolveInputCurrency(row)
|
||||
const liveAmount = resolveTRYUnitPriceByInput(
|
||||
inputPrice,
|
||||
inputCurrency,
|
||||
resolveExchangeRateValue('USD'),
|
||||
resolveExchangeRateValue('EUR'),
|
||||
resolveExchangeRateValue('GBP')
|
||||
) * quantity
|
||||
|
||||
if (Number.isFinite(liveAmount) && inputPrice > 0) return liveAmount
|
||||
return parseMoneyInput(row?.lTutar)
|
||||
}
|
||||
|
||||
function resolveRowEURTutar (row) {
|
||||
const eurAmount = Number(row?.eurTutar || 0)
|
||||
return Number.isFinite(eurAmount) ? eurAmount : 0
|
||||
return parseMoneyInput(row?.eurTutar)
|
||||
}
|
||||
|
||||
function resolveRowGBPTutar (row) {
|
||||
const gbpAmount = Number(row?.gbpTutar || 0)
|
||||
return Number.isFinite(gbpAmount) ? gbpAmount : 0
|
||||
return parseMoneyInput(row?.gbpTutar)
|
||||
}
|
||||
|
||||
function resolveRowUSDTutar (row) {
|
||||
const usdAmount = Number(row?.usdTutar || 0)
|
||||
if (Number.isFinite(usdAmount)) return usdAmount
|
||||
const quantity = resolveNumericRowQuantity(row)
|
||||
const inputPrice = resolveNumericRowInputPrice(row)
|
||||
const inputCurrency = resolveInputCurrency(row)
|
||||
const liveAmount = resolveUSDUnitPriceByInput(
|
||||
inputPrice,
|
||||
inputCurrency,
|
||||
resolveExchangeRateValue('USD'),
|
||||
resolveExchangeRateValue('EUR'),
|
||||
resolveExchangeRateValue('GBP')
|
||||
) * quantity
|
||||
|
||||
const miktar = resolveNumericRowQuantity(row)
|
||||
const dovizFiyati = Number(row?.lDovizFiyati || 0)
|
||||
const calc = miktar * dovizFiyati
|
||||
if (Number.isFinite(liveAmount) && inputPrice > 0) return liveAmount
|
||||
|
||||
const rawUSDAmount = row?.usdTutar
|
||||
if (rawUSDAmount !== undefined && rawUSDAmount !== null && String(rawUSDAmount).trim() !== '') {
|
||||
return parseMoneyInput(rawUSDAmount)
|
||||
}
|
||||
|
||||
const dovizFiyati = parseMoneyInput(row?.lDovizFiyati)
|
||||
const calc = quantity * dovizFiyati
|
||||
return Number.isFinite(calc) ? calc : 0
|
||||
}
|
||||
|
||||
function shouldIgnoreGroupMaliyeteDahil (grp) {
|
||||
return isCMGroupName(grp?.sAciklama3)
|
||||
}
|
||||
|
||||
function shouldIncludeRowInGroupTotal (grp, row) {
|
||||
return shouldIgnoreGroupMaliyeteDahil(grp) || normalizeBooleanFlag(row?.maliyeteDahil)
|
||||
if (!row) return false
|
||||
|
||||
// CM1/CM2 sub-headers always show their full group value. For every other
|
||||
// group, the explicit "Maliyete Dahil" selection controls the subtotal.
|
||||
if (isCMGroupName(grp?.sAciklama3 || row?.sAciklama3)) return true
|
||||
|
||||
const inclusionValue = row?.maliyeteDahil ?? row?.maliyete_dahil ?? row?.Maliyete_dahil
|
||||
if (inclusionValue === false || inclusionValue === 0) return false
|
||||
const normalizedValue = String(inclusionValue ?? '').trim().toLowerCase()
|
||||
return normalizedValue !== '0' && normalizedValue !== 'false' && normalizedValue !== 'hayir'
|
||||
}
|
||||
|
||||
function resolveGroupTRYTutar (grp) {
|
||||
const items = Array.isArray(grp?.items) ? grp.items : []
|
||||
return items.reduce((acc, row) => acc + (shouldIncludeRowInGroupTotal(grp, row) ? resolveRowTRYTutar(row) : 0), 0)
|
||||
function resolveGroupRowTRYAmount (row) {
|
||||
const columnAmount = parseMoneyInput(row?.lTutar)
|
||||
if (columnAmount !== 0) return columnAmount
|
||||
|
||||
const liveAmount = resolveRowTRYTutar(row)
|
||||
if (liveAmount !== 0) return liveAmount
|
||||
|
||||
return parseMoneyInput(row?.__lastValidTRYAmount ?? row?.lTutarTL)
|
||||
}
|
||||
|
||||
function resolveGroupUSDTutar (grp) {
|
||||
const items = Array.isArray(grp?.items) ? grp.items : []
|
||||
return items.reduce((acc, row) => acc + (shouldIncludeRowInGroupTotal(grp, row) ? resolveRowUSDTutar(row) : 0), 0)
|
||||
function resolveGroupRowUSDAmount (row) {
|
||||
const columnAmount = parseMoneyInput(row?.usdTutar ?? row?.lTutarUSD)
|
||||
if (columnAmount !== 0) return columnAmount
|
||||
|
||||
const liveAmount = resolveRowUSDTutar(row)
|
||||
if (liveAmount !== 0) return liveAmount
|
||||
|
||||
return parseMoneyInput(row?.__lastValidUSDAmount)
|
||||
}
|
||||
|
||||
// OrderEntry groupedRows ile ayni desen: sub-header degerleri ayri bir
|
||||
// state/cache icinde tutulmaz; iki gorunen tutar kolonu satirlardan reaktif
|
||||
// olarak her degisiklikte yeniden toplanir.
|
||||
const detailGroupsWithTotals = computed(() => (
|
||||
detailGroups.value.map(group => {
|
||||
const items = Array.isArray(group?.items) ? group.items : []
|
||||
const totals = items.reduce((acc, row) => {
|
||||
if (!shouldIncludeRowInGroupTotal(group, row)) return acc
|
||||
acc.tryTotal += resolveGroupRowTRYAmount(row)
|
||||
acc.usdTotal += resolveGroupRowUSDAmount(row)
|
||||
return acc
|
||||
}, { tryTotal: 0, usdTotal: 0 })
|
||||
|
||||
return {
|
||||
...group,
|
||||
tryTotal: totals.tryTotal,
|
||||
usdTotal: totals.usdTotal
|
||||
}
|
||||
})
|
||||
))
|
||||
|
||||
function resolveGroupQuantity (grp) {
|
||||
const items = Array.isArray(grp?.items) ? grp.items : []
|
||||
return items.reduce((acc, row) => acc + (Number(row?.lMiktar || 0) || 0), 0)
|
||||
return (Array.isArray(grp?.items) ? grp.items : []).reduce((total, row) => (
|
||||
total + resolveNumericRowQuantity(row)
|
||||
), 0)
|
||||
}
|
||||
|
||||
function groupKey (grp, gi) {
|
||||
@@ -3244,7 +3329,7 @@ function onRowQuantityInput (row, value) {
|
||||
}
|
||||
|
||||
function triggerUIUpdate () {
|
||||
detailGroups.value = [...detailGroups.value]
|
||||
// detailGroupsWithTotals is computed from the reactive row fields.
|
||||
}
|
||||
|
||||
function normalizeRowQuantityDisplay (row) {
|
||||
@@ -3342,25 +3427,26 @@ async function fetchBulkItemPrices () {
|
||||
}
|
||||
|
||||
let appliedCount = 0
|
||||
detailGroups.value = detailGroups.value.map(grp => ({
|
||||
...grp,
|
||||
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => {
|
||||
detailGroups.value.forEach(grp => {
|
||||
const items = Array.isArray(grp?.items) ? grp.items : []
|
||||
items.forEach(row => {
|
||||
const match = updates.find(update => rowMatchesBulkUpdate(row, update))
|
||||
if (!match) return row
|
||||
if (!match) return
|
||||
appliedCount += 1
|
||||
return recalculateDetailRow({
|
||||
...row,
|
||||
inputPrice: match.inputPrice,
|
||||
fiyat_girilen: match.fiyat_girilen,
|
||||
inputPricePrBr: match.inputPricePrBr,
|
||||
fiyat_doviz: match.fiyat_doviz
|
||||
}, {
|
||||
row.inputPrice = match.inputPrice
|
||||
row.fiyat_girilen = match.fiyat_girilen
|
||||
row.inputPricePrBr = match.inputPricePrBr
|
||||
row.fiyat_doviz = match.fiyat_doviz
|
||||
recalculateDetailRow(row, {
|
||||
priceType: match.priceType || 'SAF',
|
||||
updateState: 'bulk',
|
||||
markChanged: true
|
||||
})
|
||||
})
|
||||
}))
|
||||
})
|
||||
// Update sub-headers immediately after the bulk response is applied. Do
|
||||
// not wait for the optional previous-cost autofill request below.
|
||||
triggerUIUpdate()
|
||||
|
||||
$q.notify({
|
||||
type: appliedCount > 0 ? 'positive' : 'warning',
|
||||
@@ -3400,14 +3486,14 @@ async function fetchBulkItemPrices () {
|
||||
})
|
||||
|
||||
let filled = 0
|
||||
detailGroups.value = detailGroups.value.map(grp => ({
|
||||
...grp,
|
||||
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => {
|
||||
if (!row?.requiredPlaceholder) return row
|
||||
if (isCMGroupName(row?.sAciklama3) || normalizeGroupName(row?.sAciklama3) === 'FABRIC') return row
|
||||
detailGroups.value.forEach(grp => {
|
||||
const items = Array.isArray(grp?.items) ? grp.items : []
|
||||
items.forEach(row => {
|
||||
if (!row?.requiredPlaceholder) return
|
||||
if (isCMGroupName(row?.sAciklama3) || normalizeGroupName(row?.sAciklama3) === 'FABRIC') return
|
||||
const no = parseInt(String(row?.nHammaddeTuruNo || '').trim() || '0', 10) || 0
|
||||
const hit = byNo[no]
|
||||
if (!hit) return row
|
||||
if (!hit) return
|
||||
|
||||
const next = { ...row }
|
||||
const hasCode = String(next.sKodu || '').trim() !== ''
|
||||
@@ -3427,20 +3513,20 @@ async function fetchBulkItemPrices () {
|
||||
|
||||
// Only mark if something changed
|
||||
const changed = (next.sKodu !== row.sKodu) || (next.sAciklama !== row.sAciklama) || (next.inputPrice !== row.inputPrice) || (next.inputPricePrBr !== row.inputPricePrBr)
|
||||
if (!changed) return row
|
||||
if (!changed) return
|
||||
filled += 1
|
||||
return recalculateDetailRow({
|
||||
...next,
|
||||
__autoFilledFromPrev: true
|
||||
,
|
||||
Object.assign(row, next, {
|
||||
__autoFilledFromPrev: true,
|
||||
__autoFilledFromPrevSameFirma: Boolean(hit?.is_same_firma || hit?.isSameFirma)
|
||||
}, {
|
||||
})
|
||||
recalculateDetailRow(row, {
|
||||
priceType: 'PREV',
|
||||
updateState: 'autofill',
|
||||
markChanged: true
|
||||
})
|
||||
})
|
||||
}))
|
||||
})
|
||||
triggerUIUpdate()
|
||||
|
||||
if (filled > 0) {
|
||||
$q.notify({
|
||||
@@ -3457,6 +3543,11 @@ async function fetchBulkItemPrices () {
|
||||
error: String(e?.message || e || '')
|
||||
})
|
||||
}
|
||||
// Bulk price and previous-cost autofill paths can update many rows at once.
|
||||
// Recalculate once more as a whole so group sub-header totals react immediately.
|
||||
recalculateAllDetailRows()
|
||||
schedulePersistLocalDraft()
|
||||
triggerUIUpdate()
|
||||
} catch (err) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
@@ -3928,10 +4019,14 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) {
|
||||
const groupName = normalizeGroupName(meta?.groupName || '')
|
||||
const hammaddeAdi = String(meta?.hammaddeAdi || '').trim()
|
||||
const effectiveGroupName = groupName || 'TANIMSIZ'
|
||||
const mtBolumID = (meta?.mtBolumID > 0 ? meta.mtBolumID : mappingMtBolumID) || 0
|
||||
const metaParcaCandidate = normalizeGroupName((meta?.parcaAdi || '').trim())
|
||||
// Defensive: if backend/lookup accidentally returns group label (DT/TP/...) as part name, ignore it.
|
||||
const desiredParcaAdi = normalizeGroupName((metaParcaCandidate && !isKnownGroupName(metaParcaCandidate)) ? metaParcaCandidate : mappingParcaAdi)
|
||||
// The product-type mapping is authoritative for the required part. A raw
|
||||
// material type can be required for multiple parts, while its master
|
||||
// record only carries one default MT section. Using that default here
|
||||
// incorrectly made an existing recipe row satisfy every mapped part.
|
||||
const mtBolumID = mappingMtBolumID > 0 ? mappingMtBolumID : (meta?.mtBolumID || 0)
|
||||
const desiredParcaAdi = mappingMtBolumID > 0
|
||||
? mappingParcaAdi
|
||||
: normalizeGroupName(meta?.parcaAdi || mappingParcaAdi)
|
||||
|
||||
const anyMatch = flatDetailRows.value.find(r => {
|
||||
if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false
|
||||
@@ -5062,13 +5157,31 @@ async function saveChanges () {
|
||||
|
||||
// If we created a new OnML (no-cost), switch to has-cost detail mode.
|
||||
if (isNoCostDetail.value && newOnMLNo > 0) {
|
||||
router.replace({
|
||||
await router.replace({
|
||||
name: 'production-product-costing-has-cost-detail',
|
||||
query: {
|
||||
n_onml_no: String(newOnMLNo),
|
||||
urun_kodu: String(header?.UrunKodu || productCode.value || '').trim()
|
||||
}
|
||||
})
|
||||
|
||||
$q.dialog({
|
||||
title: 'Maliyet Kaydedildi',
|
||||
message: 'Maliyeti olmayan urunler listesine donup kaldiginiz yerden devam etmek ister misiniz?',
|
||||
ok: {
|
||||
label: 'Listeye Don',
|
||||
color: 'primary',
|
||||
icon: 'arrow_back'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Bu Sayfada Devam Et',
|
||||
color: 'grey-7',
|
||||
flat: true
|
||||
},
|
||||
persistent: true
|
||||
}).onOk(() => {
|
||||
router.push({ name: 'production-product-costing-no-cost' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5078,6 +5191,24 @@ async function saveChanges () {
|
||||
window.setTimeout(() => {
|
||||
try { refreshLast10Warnings() } catch {}
|
||||
}, 1200)
|
||||
|
||||
$q.dialog({
|
||||
title: 'Maliyet Guncellendi',
|
||||
message: 'Mevcut maliyeti olan urunler listesine donup kaldiginiz yerden devam etmek ister misiniz?',
|
||||
ok: {
|
||||
label: 'Listeye Don',
|
||||
color: 'primary',
|
||||
icon: 'arrow_back'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Bu Sayfada Devam Et',
|
||||
color: 'grey-7',
|
||||
flat: true
|
||||
},
|
||||
persistent: true
|
||||
}).onOk(() => {
|
||||
router.push({ name: 'production-product-costing-has-cost' })
|
||||
})
|
||||
} catch (e) {
|
||||
// Surface backend message (http.Error text) when available.
|
||||
const msg = String(
|
||||
@@ -5526,8 +5657,8 @@ watch(
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
min-height: 42px;
|
||||
height: 42px;
|
||||
min-height: 46px;
|
||||
height: auto;
|
||||
border-top: 1px solid #d6c06a;
|
||||
border-bottom: 1px solid #d6c06a;
|
||||
background: linear-gradient(90deg, #fffbe9 0%, #fff4c4 50%, #fff1b0 100%);
|
||||
@@ -5549,7 +5680,9 @@ watch(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
max-width: 70%;
|
||||
}
|
||||
.pcd-sub-header .sub-left {
|
||||
display: flex;
|
||||
@@ -5566,8 +5699,31 @@ watch(
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pcd-group-total-text {
|
||||
color: #2f2408;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pcd-group-total-divider {
|
||||
margin: 0 4px;
|
||||
color: #8d6d13;
|
||||
}
|
||||
.pcd-group-debug {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid rgba(180, 35, 24, 0.35);
|
||||
border-radius: 5px;
|
||||
color: #8f1d14;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pcd-sub-mt-qty {
|
||||
@@ -5577,6 +5733,18 @@ watch(
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pcd-sub-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.pcd-sub-header .sub-right {
|
||||
max-width: 78%;
|
||||
}
|
||||
.pcd-group-debug {
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pcd-detail-table :deep(.q-table__middle) {
|
||||
overflow: visible !important;
|
||||
}
|
||||
@@ -5717,12 +5885,69 @@ watch(
|
||||
}
|
||||
|
||||
.pcd-history-table :deep(.q-table thead th) {
|
||||
font-size: 12px;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.1;
|
||||
white-space: normal;
|
||||
padding: 3px 2px;
|
||||
}
|
||||
|
||||
.pcd-history-table :deep(.q-table tbody td) {
|
||||
font-size: 12px;
|
||||
font-size: 9.5px;
|
||||
vertical-align: top;
|
||||
padding: 2px;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.pcd-history-table :deep(.q-table__middle) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.pcd-history-table :deep(.q-table) {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcd-history-table :deep(.q-table th),
|
||||
.pcd-history-table :deep(.q-table td) {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.pcd-history-table :deep(.q-table th:nth-child(1)), .pcd-history-table :deep(.q-table td:nth-child(1)) { width: 5% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(2)), .pcd-history-table :deep(.q-table td:nth-child(2)) { width: 6% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(3)), .pcd-history-table :deep(.q-table td:nth-child(3)) { width: 7% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(4)), .pcd-history-table :deep(.q-table td:nth-child(4)) { width: 6% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(5)), .pcd-history-table :deep(.q-table td:nth-child(5)) { width: 8% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(6)), .pcd-history-table :deep(.q-table td:nth-child(6)) { width: 7% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(7)), .pcd-history-table :deep(.q-table td:nth-child(7)) { width: 10% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(8)), .pcd-history-table :deep(.q-table td:nth-child(8)) { width: 5% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(9)), .pcd-history-table :deep(.q-table td:nth-child(9)) { width: 7% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(10)), .pcd-history-table :deep(.q-table td:nth-child(10)) { width: 5% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(11)), .pcd-history-table :deep(.q-table td:nth-child(11)) { width: 7% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(12)), .pcd-history-table :deep(.q-table td:nth-child(12)) { width: 5% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(13)), .pcd-history-table :deep(.q-table td:nth-child(13)) { width: 3% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(14)), .pcd-history-table :deep(.q-table td:nth-child(14)) { width: 5% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(15)), .pcd-history-table :deep(.q-table td:nth-child(15)) { width: 6% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(16)), .pcd-history-table :deep(.q-table td:nth-child(16)) { width: 4% !important; }
|
||||
.pcd-history-table :deep(.q-table th:nth-child(17)), .pcd-history-table :deep(.q-table td:nth-child(17)) { width: 4% !important; }
|
||||
|
||||
.pcd-history-table :deep(.pcd-history-source-chip) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 2px 4px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.pcd-history-table :deep(.q-btn) {
|
||||
min-width: 0;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.pcd-history-table :deep(.pcd-history-row-purchase td) {
|
||||
|
||||
@@ -49,6 +49,14 @@
|
||||
:loading="loading"
|
||||
@click="fetchRows"
|
||||
/>
|
||||
<q-btn
|
||||
label="Excel'e Aktar"
|
||||
icon="download"
|
||||
color="primary"
|
||||
outline
|
||||
:disable="loading || rows.length === 0"
|
||||
@click="exportVisibleRows"
|
||||
/>
|
||||
<div class="npc-missing-count text-caption text-grey-7">
|
||||
Maliyeti Girilmemis Satir Sayisi: <b>{{ missingCostRowCount }}</b>
|
||||
</div>
|
||||
@@ -176,7 +184,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePermission } from 'src/composables/usePermission'
|
||||
@@ -244,6 +252,9 @@ const columns = [
|
||||
style: 'width:7%',
|
||||
headerStyle: 'width:7%'
|
||||
},
|
||||
{ name: 'ceketDepoGiris', label: 'C-Ceket Depo Giris', field: 'ceketDepoGiris', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width:7%', headerStyle: 'width:7%' },
|
||||
{ name: 'pantolonDepoGiris', label: 'P-Pantolon Depo Giris', field: 'pantolonDepoGiris', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width:7%', headerStyle: 'width:7%' },
|
||||
{ name: 'yelekDepoGiris', label: 'Y-Yelek Depo Giris', field: 'yelekDepoGiris', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width:7%', headerStyle: 'width:7%' },
|
||||
{
|
||||
name: 'sMModelKodu',
|
||||
label: 'Model Kodu',
|
||||
@@ -262,6 +273,34 @@ const columns = [
|
||||
]
|
||||
|
||||
const columnFilters = reactive({})
|
||||
const LIST_STATE_STORAGE_KEY = 'bssapp:production-product-costing:no-cost-list-state:v1'
|
||||
|
||||
function persistListState () {
|
||||
try {
|
||||
sessionStorage.setItem(LIST_STATE_STORAGE_KEY, JSON.stringify({
|
||||
filters: {
|
||||
search: String(filters.search || ''),
|
||||
fromDate: String(filters.fromDate || '')
|
||||
},
|
||||
columnFilters: JSON.parse(JSON.stringify(columnFilters)),
|
||||
scrollY: Number(window.scrollY || 0)
|
||||
}))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function restoreListState () {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(LIST_STATE_STORAGE_KEY)
|
||||
if (!raw) return 0
|
||||
const saved = JSON.parse(raw)
|
||||
filters.search = String(saved?.filters?.search || '')
|
||||
filters.fromDate = String(saved?.filters?.fromDate || '2025-06-01')
|
||||
Object.assign(columnFilters, saved?.columnFilters && typeof saved.columnFilters === 'object' ? saved.columnFilters : {})
|
||||
return Number(saved?.scrollY || 0)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function getColumnFilter (name) {
|
||||
if (!columnFilters[name]) {
|
||||
@@ -276,7 +315,11 @@ function getColumnFilter (name) {
|
||||
}
|
||||
|
||||
function isNumericColumn (name) {
|
||||
return name === 'lMMiktar_G'
|
||||
return ['lMMiktar_G', 'ceketDepoGiris', 'pantolonDepoGiris', 'yelekDepoGiris'].includes(name)
|
||||
}
|
||||
|
||||
function formatQuantity (value) {
|
||||
return Number(value || 0).toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function parseNumberFilter (value) {
|
||||
@@ -384,9 +427,11 @@ function clearAllColumnFilters () {
|
||||
}
|
||||
|
||||
let searchTimer = null
|
||||
let filterWatchEnabled = false
|
||||
watch(
|
||||
() => filters.search,
|
||||
() => {
|
||||
if (!filterWatchEnabled) return
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
fetchRows()
|
||||
@@ -397,6 +442,7 @@ watch(
|
||||
watch(
|
||||
() => filters.fromDate,
|
||||
() => {
|
||||
if (!filterWatchEnabled) return
|
||||
fetchRows()
|
||||
}
|
||||
)
|
||||
@@ -430,6 +476,49 @@ function clearFilters () {
|
||||
fetchRows()
|
||||
}
|
||||
|
||||
function escapeExcelCsvCell (value) {
|
||||
let text = String(value ?? '')
|
||||
// Prevent spreadsheet applications from evaluating data as a formula.
|
||||
if (/^[=+\-@]/.test(text)) text = `'${text}`
|
||||
return `"${text.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function formatExcelCsvCell (col, rawValue, displayValue) {
|
||||
if (isNumericColumn(col?.name)) {
|
||||
const numericValue = Number(rawValue)
|
||||
if (Number.isFinite(numericValue)) return String(numericValue).replace('.', ',')
|
||||
}
|
||||
return escapeExcelCsvCell(displayValue)
|
||||
}
|
||||
|
||||
function exportVisibleRows () {
|
||||
const visibleRows = Array.isArray(rows.value) ? rows.value : []
|
||||
if (visibleRows.length === 0) {
|
||||
$q.notify({ type: 'warning', message: 'Excel aktarimi icin ekranda satir bulunamadi.', position: 'top-right' })
|
||||
return
|
||||
}
|
||||
|
||||
const exportColumns = columns.filter(col => col.name !== 'open')
|
||||
const csvLines = [
|
||||
exportColumns.map(col => escapeExcelCsvCell(col.label)).join(';'),
|
||||
...visibleRows.map(row => exportColumns.map(col => {
|
||||
const rawValue = typeof col.field === 'function' ? col.field(row) : row?.[col.field]
|
||||
const displayValue = typeof col.format === 'function' ? col.format(rawValue, row) : rawValue
|
||||
return formatExcelCsvCell(col, rawValue, displayValue)
|
||||
}).join(';'))
|
||||
]
|
||||
|
||||
const blob = new Blob([`\uFEFF${csvLines.join('\r\n')}`], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `maliyeti_olmayan_urunler_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function openRow (row) {
|
||||
const productCode = String(row?.sMModelKodu || '').trim()
|
||||
const recipeCode = String(row?.sKodu || '').trim()
|
||||
@@ -449,6 +538,8 @@ function openRow (row) {
|
||||
recipe_code: recipeCode
|
||||
})
|
||||
|
||||
persistListState()
|
||||
|
||||
router.push({
|
||||
name: 'production-product-costing-has-cost-detail',
|
||||
query: {
|
||||
@@ -460,9 +551,16 @@ function openRow (row) {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
if (!canReadOrder.value) return
|
||||
fetchRows()
|
||||
const savedScrollY = restoreListState()
|
||||
await nextTick()
|
||||
filterWatchEnabled = true
|
||||
await fetchRows()
|
||||
if (savedScrollY > 0) {
|
||||
await nextTick()
|
||||
window.scrollTo({ top: savedScrollY, behavior: 'auto' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,37 +4,38 @@ import api from 'src/services/api'
|
||||
const GROUPED_TTL_MS = 2 * 60 * 1000
|
||||
const IMAGE_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
function normalizeUploadsPath (storagePath) {
|
||||
const raw = String(storagePath || '').trim()
|
||||
if (!raw) return ''
|
||||
const normalized = raw.replace(/\\/g, '/')
|
||||
const idx = normalized.toLowerCase().indexOf('/uploads/')
|
||||
if (idx >= 0) return normalized.slice(idx)
|
||||
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
|
||||
return ''
|
||||
function resolveProductImageUrl (item) {
|
||||
return resolveProductImageUrls(item)[0] || ''
|
||||
}
|
||||
|
||||
function resolveProductImageUrl (item) {
|
||||
if (!item || typeof item !== 'object') return ''
|
||||
|
||||
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||
if (contentURL.startsWith('/api/')) return contentURL
|
||||
if (contentURL.startsWith('/')) return `/api${contentURL}`
|
||||
|
||||
const imageId = Number(item.id || item.ID || 0)
|
||||
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
|
||||
function resolveProductImageUrls (item) {
|
||||
if (!item || typeof item !== 'object') return []
|
||||
const urls = []
|
||||
const addURL = value => {
|
||||
const url = String(value || '').trim()
|
||||
if (url && !urls.includes(url)) urls.push(url)
|
||||
}
|
||||
|
||||
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
|
||||
if (thumbURL) return thumbURL
|
||||
addURL(thumbURL)
|
||||
|
||||
const fullURL = String(item.full_url || item.fullUrl || '').trim()
|
||||
if (fullURL) return fullURL
|
||||
addURL(fullURL)
|
||||
|
||||
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
|
||||
if (uploadsPath) return uploadsPath
|
||||
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||
if (contentURL.startsWith('/api/')) addURL(contentURL)
|
||||
else if (contentURL.startsWith('/')) addURL(`/api${contentURL}`)
|
||||
return urls
|
||||
}
|
||||
|
||||
const fileName = String(item.file_name || item.FileName || '').trim()
|
||||
return fileName ? `/uploads/image/${fileName}` : ''
|
||||
function normalizedFilterMap (filters = {}) {
|
||||
return Object.fromEntries(Object.entries(filters || {})
|
||||
.map(([key, values]) => [
|
||||
String(key || '').trim(),
|
||||
normalizedList(values, true)
|
||||
])
|
||||
.filter(([key, values]) => key && values.length)
|
||||
.sort((a, b) => a[0].localeCompare(b[0], 'tr')))
|
||||
}
|
||||
|
||||
function normalizedList (value, sort = false) {
|
||||
@@ -49,6 +50,9 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
groupedLoadedAtByKey: {},
|
||||
groupedLoadingByKey: {},
|
||||
groupedInFlightByKey: {},
|
||||
groupedFilterOptionsByKey: {},
|
||||
groupedFilterOptionsLoadedAtByKey: {},
|
||||
groupedFilterOptionsInFlightByKey: {},
|
||||
imageUrlByKey: {},
|
||||
imageListByKey: {},
|
||||
imageLoadedAtByKey: {}
|
||||
@@ -68,6 +72,20 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
mainGroup: String(params.urunAnaGrubu || params.urun_ana_grubu || ''),
|
||||
groupLevels: normalizedList(params.groupLevels),
|
||||
expandedKeys: normalizedList(params.expandedKeys, true),
|
||||
expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
|
||||
filters: normalizedFilterMap(params.filters),
|
||||
sortBy: String(params.sortBy || params.sort_by || ''),
|
||||
descending: params.descending !== false,
|
||||
limit: Number(params.limit || 0)
|
||||
})
|
||||
},
|
||||
|
||||
groupedFilterOptionsCacheKey (params = {}) {
|
||||
return JSON.stringify({
|
||||
mode: String(params.mode || ''),
|
||||
mainGroup: String(params.urunAnaGrubu || params.urun_ana_grubu || ''),
|
||||
groupLevels: normalizedList(params.groupLevels),
|
||||
fields: normalizedList(params.fields, true),
|
||||
limit: Number(params.limit || 0)
|
||||
})
|
||||
},
|
||||
@@ -90,7 +108,8 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
console.info(`${logPrefix} in-flight reuse`, {
|
||||
mode: params.mode,
|
||||
groupLevels: normalizedList(params.groupLevels),
|
||||
expandedKeys: normalizedList(params.expandedKeys, true).length
|
||||
expandedKeys: normalizedList(params.expandedKeys, true).length,
|
||||
expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1)
|
||||
})
|
||||
return this.groupedInFlightByKey[key]
|
||||
}
|
||||
@@ -101,6 +120,7 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
mode: params.mode,
|
||||
groupLevels: normalizedList(params.groupLevels),
|
||||
expandedKeys: normalizedList(params.expandedKeys, true).length,
|
||||
expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
|
||||
limit: params.limit
|
||||
})
|
||||
const request = api.post('/pricing/product-performance/grouped', {
|
||||
@@ -108,12 +128,19 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '',
|
||||
group_levels: Array.isArray(params.groupLevels) ? params.groupLevels : String(params.groupLevels || '').split(',').filter(Boolean),
|
||||
expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys : String(params.expandedKeys || '').split(',').filter(Boolean),
|
||||
expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
|
||||
filters: normalizedFilterMap(params.filters),
|
||||
sort_by: String(params.sortBy || params.sort_by || ''),
|
||||
descending: params.descending !== false,
|
||||
limit: params.limit
|
||||
}, {
|
||||
params: {
|
||||
mode: params.mode,
|
||||
limit: params.limit,
|
||||
urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || ''
|
||||
urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '',
|
||||
expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
|
||||
sort_by: String(params.sortBy || params.sort_by || ''),
|
||||
descending: params.descending !== false
|
||||
},
|
||||
timeout: 90000
|
||||
}).then(resp => {
|
||||
@@ -127,10 +154,11 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
})
|
||||
return rows
|
||||
}).catch(err => {
|
||||
console.warn(`${logPrefix} request failed`, {
|
||||
const message = err?.response?.data || err?.message || String(err || '')
|
||||
console.warn(`${logPrefix} request failed: ${message}`, {
|
||||
mode: params.mode,
|
||||
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)),
|
||||
message: err?.response?.data || err?.message || err
|
||||
message
|
||||
})
|
||||
throw err
|
||||
}).finally(() => {
|
||||
@@ -146,6 +174,35 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
return request
|
||||
},
|
||||
|
||||
async fetchGroupedFilterOptions (params = {}, options = {}) {
|
||||
const key = this.groupedFilterOptionsCacheKey(params)
|
||||
const now = Date.now()
|
||||
const loadedAt = Number(this.groupedFilterOptionsLoadedAtByKey[key] || 0)
|
||||
const cached = this.groupedFilterOptionsByKey[key]
|
||||
if (!options.force && cached && now - loadedAt < GROUPED_TTL_MS) return cached
|
||||
if (this.groupedFilterOptionsInFlightByKey[key]) return this.groupedFilterOptionsInFlightByKey[key]
|
||||
|
||||
const request = api.post('/pricing/product-performance/grouped/filter-options', {
|
||||
mode: params.mode,
|
||||
urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '',
|
||||
group_levels: Array.isArray(params.groupLevels) ? params.groupLevels : String(params.groupLevels || '').split(',').filter(Boolean),
|
||||
fields: Array.isArray(params.fields) ? params.fields : String(params.fields || '').split(',').filter(Boolean),
|
||||
limit: params.limit || 50000
|
||||
}, { timeout: 60000 }).then(resp => {
|
||||
const data = resp?.data && typeof resp.data === 'object' ? resp.data : {}
|
||||
this.groupedFilterOptionsByKey = { ...this.groupedFilterOptionsByKey, [key]: data }
|
||||
this.groupedFilterOptionsLoadedAtByKey = { ...this.groupedFilterOptionsLoadedAtByKey, [key]: Date.now() }
|
||||
return data
|
||||
}).finally(() => {
|
||||
const nextInFlight = { ...this.groupedFilterOptionsInFlightByKey }
|
||||
delete nextInFlight[key]
|
||||
this.groupedFilterOptionsInFlightByKey = nextInFlight
|
||||
})
|
||||
|
||||
this.groupedFilterOptionsInFlightByKey = { ...this.groupedFilterOptionsInFlightByKey, [key]: request }
|
||||
return request
|
||||
},
|
||||
|
||||
setImageCache (key, urls = []) {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
if (!normalizedKey) return
|
||||
@@ -189,7 +246,7 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
||||
const key = String(item?.key || '').trim()
|
||||
if (!key) continue
|
||||
returned.add(key)
|
||||
const urls = (Array.isArray(item.images) ? item.images : []).map(resolveProductImageUrl).filter(Boolean)
|
||||
const urls = (Array.isArray(item.images) ? item.images : []).flatMap(resolveProductImageUrls).filter(Boolean)
|
||||
this.setImageCache(key, urls)
|
||||
lists[key] = urls
|
||||
urlsByKey[key] = urls[0] || ''
|
||||
|
||||
Reference in New Issue
Block a user