Compare commits

...
81 Commits
Author SHA1 Message Date
M_Kececi db13574e9d Fix product performance grouped JSON build 2026-08-19 01:10:20 +03:00
M_Kececi ba9a744aef Fix product performance grouped JSON build 2026-08-19 01:06:55 +03:00
M_Kececi 82ad3783b3 Fix product performance grouped JSON build 2026-08-19 01:00:03 +03:00
M_Kececi 322bed753f Fix product performance grouped JSON build 2026-08-19 00:53:51 +03:00
M_Kececi bdc9459330 Fix product performance grouped JSON build 2026-08-19 00:50:40 +03:00
M_Kececi 2d85a33623 Fix product performance grouped JSON build 2026-08-19 00:45:58 +03:00
M_Kececi 5f1c37859b Fix product performance grouped JSON build 2026-08-19 00:08:19 +03:00
M_Kececi 31a71ab566 Fix product performance grouped JSON build 2026-08-18 23:38:24 +03:00
M_Kececi 25ef023df5 Fix product performance grouped JSON build 2026-08-18 23:32:39 +03:00
M_Kececi 0c51729f7a Fix product performance grouped JSON build 2026-08-18 22:41:08 +03:00
M_Kececi 20f5fea2e8 Fix product performance grouped JSON build 2026-08-18 22:35:20 +03:00
M_Kececi 65e8aa2b62 Fix product performance grouped JSON build 2026-08-18 22:23:41 +03:00
M_Kececi 1f8355b77c Fix product performance grouped JSON build 2026-08-18 22:17:28 +03:00
M_Kececi 11048f1555 Fix product performance grouped JSON build 2026-08-18 22:11:13 +03:00
M_Kececi b83a3d22ce Fix product performance grouped JSON build 2026-08-18 22:07:33 +03:00
M_Kececi 77af9ef533 Fix product performance grouped JSON build 2026-08-18 22:02:44 +03:00
M_Kececi 4395d6bae2 Fix product performance grouped JSON build 2026-08-18 21:57:46 +03:00
M_Kececi 6bc19e7a43 Fix product performance grouped JSON build 2026-08-18 21:54:16 +03:00
M_Kececi 797d069e59 Fix product performance grouped JSON build 2026-08-18 21:47:27 +03:00
M_Kececi 36e0ec9ff5 Fix product performance grouped JSON build 2026-08-18 21:39:53 +03:00
M_Kececi f14a96a024 Fix product performance grouped JSON build 2026-08-18 21:35:10 +03:00
M_Kececi f877b611b7 Fix product performance grouped JSON build 2026-08-18 21:13:38 +03:00
M_Kececi dbb3f3f380 Fix product performance grouped JSON build 2026-08-18 21:07:52 +03:00
M_Kececi 03287d4850 Fix product performance grouped JSON build 2026-08-18 20:58:29 +03:00
M_Kececi 12ce3a3eca Fix product performance grouped JSON build 2026-08-18 20:54:45 +03:00
M_Kececi 52420bc5ff Fix product performance grouped JSON build 2026-08-18 20:40:39 +03:00
M_Kececi d5aa43ccb5 Fix product performance grouped JSON build 2026-08-18 20:30:44 +03:00
M_Kececi 04461acb58 Fix product performance grouped JSON build 2026-08-18 20:23:55 +03:00
M_Kececi cd101811c4 Fix product performance grouped JSON build 2026-08-18 20:19:24 +03:00
M_Kececi c4028010c0 Fix product performance grouped JSON build 2026-08-18 20:13:21 +03:00
M_Kececi c2d010751f Fix product performance grouped JSON build 2026-08-18 20:08:09 +03:00
M_Kececi a9ab9fd9b0 Fix product performance grouped JSON build 2026-08-18 20:03:12 +03:00
M_Kececi 1945fd4456 Fix product performance grouped JSON build 2026-08-18 19:57:24 +03:00
M_Kececi 7b8f8905e5 Fix product performance grouped JSON build 2026-08-18 19:44:36 +03:00
M_Kececi 89df3d2d3d Fix product performance grouped JSON build 2026-08-18 18:44:26 +03:00
M_Kececi 474904481f Fix product performance grouped JSON build 2026-08-18 13:38:32 +03:00
M_Kececi 6eaf0e3ed3 Fix product performance grouped JSON build 2026-08-18 13:23:10 +03:00
M_Kececi 8249761415 Fix product performance grouped JSON build 2026-08-18 13:10:11 +03:00
M_Kececi dbd84f7399 Fix product performance grouped JSON build 2026-08-18 13:04:50 +03:00
M_Kececi 9edc806345 Fix product performance grouped JSON build 2026-08-18 12:45:11 +03:00
M_Kececi 7a7008cc1b Fix product performance grouped JSON build 2026-07-14 00:18:23 +03:00
M_Kececi 3e0253f6be Fix product performance grouped JSON build 2026-07-13 22:43:51 +03:00
M_Kececi b80b5024f4 Fix product performance grouped JSON build 2026-07-13 21:55:17 +03:00
M_Kececi 5eaf8be9e6 Fix product performance grouped JSON build 2026-07-12 16:15:41 +03:00
M_Kececi cc2930caf2 Fix product performance grouped JSON build 2026-07-09 16:41:41 +03:00
M_Kececi 26b4a63c45 Fix product performance grouped JSON build 2026-07-07 23:51:29 +03:00
M_Kececi c6a3d1552e Fix product performance grouped JSON build 2026-07-07 12:59:19 +03:00
M_Kececi 005d2eafac Fix product performance grouped JSON build 2026-07-07 01:21:07 +03:00
M_Kececi 289f204752 Fix product performance grouped JSON build 2026-07-06 18:01:34 +03:00
M_Kececi 1448dab9d7 Fix product performance grouped JSON build 2026-07-06 17:01:39 +03:00
M_Kececi 5d4216d42e Fix product performance grouped JSON build 2026-07-06 16:54:36 +03:00
M_Kececi 3993c363cb Fix product performance grouped JSON build 2026-07-06 01:28:44 +03:00
M_Kececi a0da3f2bed Fix product performance grouped JSON build 2026-07-06 00:53:30 +03:00
M_Kececi 19a42e0551 Merge remote-tracking branch 'origin/master' 2026-07-05 23:43:20 +03:00
M_Kececi 53cdd1a4a1 Fix product performance grouped JSON build 2026-07-05 23:43:17 +03:00
M_Kececi fe29e763c5 Fix product performance grouped JSON build 2026-07-05 23:41:26 +03:00
M_Kececi 9e1ecb9b3f Fix product performance grouped JSON build 2026-07-05 22:06:43 +03:00
M_Kececi fbdde54eee Fix product performance grouped JSON build 2026-07-05 20:31:27 +03:00
M_Kececi b2113a8d49 Fix product performance grouped JSON build 2026-07-05 18:31:40 +03:00
M_Kececi 28e33d5e58 Fix product performance grouped JSON build 2026-07-05 17:02:28 +03:00
M_Kececi fbdc4934ff Merge remote-tracking branch 'origin/master' 2026-07-05 15:30:29 +03:00
M_Kececi 5b512fd30a Fix product performance grouped JSON build 2026-07-05 15:30:25 +03:00
M_Kececi 7879172c54 Fix product performance grouped JSON build 2026-07-05 15:29:59 +03:00
M_Kececi c602e1e119 Fix product performance grouped JSON build 2026-07-05 13:54:52 +03:00
M_Kececi 27934bccdf Fix product performance grouped JSON build 2026-07-05 05:16:31 +03:00
M_Kececi 48bde09c2c Fix product performance grouped JSON build 2026-07-05 02:58:06 +03:00
M_Kececi 480cad0d6f Fix product performance grouped JSON build 2026-07-05 01:02:04 +03:00
M_Kececi 9f6e288d04 Fix product performance grouped JSON build 2026-07-05 00:38:44 +03:00
M_Kececi d0717900a6 Fix product performance grouped JSON build 2026-07-04 23:31:45 +03:00
M_Kececi 25bc9b2d02 Fix product performance grouped JSON build 2026-07-04 14:11:19 +03:00
M_Kececi 4c56062ef4 Fix product performance grouped JSON build 2026-07-04 13:45:43 +03:00
M_Kececi 60b2f76537 Fix product performance grouped JSON build 2026-07-04 13:26:10 +03:00
M_Kececi 81384426ff Fix product performance grouped JSON build 2026-07-04 13:10:34 +03:00
M_Kececi 3697e35064 Fix product performance grouped JSON build 2026-07-03 21:35:14 +03:00
M_Kececi 4ae6475875 Merge remote-tracking branch 'origin/master' 2026-07-03 20:53:53 +03:00
M_Kececi 5f006b8be0 Fix product performance grouped JSON build 2026-07-03 20:53:48 +03:00
M_Kececi 38c168f7bd Fix product performance grouped JSON build 2026-07-03 20:52:48 +03:00
M_Kececi be125a6880 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	svc/queries/product_performance_sql.go
#	ui/src/pages/ProductPerformanceProfitability.vue
2026-07-03 20:46:31 +03:00
M_Kececi 3b2bcda462 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	svc/queries/product_performance_sql.go
#	ui/src/pages/ProductPerformanceProfitability.vue
2026-07-03 20:40:35 +03:00
M_Kececi 9e22cc5ec5 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	svc/queries/product_performance_sql.go
#	ui/src/pages/ProductPerformanceProfitability.vue
2026-07-03 20:10:05 +03:00
M_Kececi 5e2a85b98e product performance grouped kpi improvements 2026-07-03 20:09:39 +03:00
25 changed files with 8432 additions and 1314 deletions
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4"> <module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" /> <component name="Go" enabled="true" />
<component name="GoModuleSettings" enabled="true" />
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
+2
View File
@@ -3,6 +3,8 @@ bssapp
# Env # Env
.env .env
.env.local
.env.argebaggi.local
mail.env mail.env
# Runtime fonts # Runtime fonts
+2 -1
View File
@@ -66,10 +66,11 @@ func main() {
log.Fatalf("refresh failed: %v", err) 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.SalesRows,
result.StockRows, result.StockRows,
result.KpiRows, result.KpiRows,
result.SnapshotRows,
) )
} }
+39
View File
@@ -14,6 +14,7 @@ import (
var MssqlDB *sql.DB var MssqlDB *sql.DB
var UretimDB *sql.DB var UretimDB *sql.DB
var ArgeBaggiDB *sql.DB
func envInt(name string, fallback int) int { func envInt(name string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(name)) raw := strings.TrimSpace(os.Getenv(name))
@@ -158,3 +159,41 @@ func ConnectMSSQLUretim() error {
func GetUretimDB() *sql.DB { func GetUretimDB() *sql.DB {
return UretimDB 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
View File
@@ -227,6 +227,7 @@ InitRoutes — FULL V3 (Method-aware) PERMISSION EDITION
func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router { func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router {
r := mux.NewRouter() r := mux.NewRouter()
mountUploads(r)
mountSPA(r) mountSPA(r)
/* /*
@@ -939,6 +940,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "view", "pricing", "view",
wrapV3(routes.GetProductPerformanceHandler(pgDB)), wrapV3(routes.GetProductPerformanceHandler(pgDB)),
) )
bindV3(r, pgDB,
"/api/pricing/product-performance/export-excel", "POST",
"pricing", "view",
wrapV3(routes.ExportProductPerformanceExcelHandler(pgDB)),
)
bindV3(r, pgDB, bindV3(r, pgDB,
"/api/pricing/product-performance/summary", "GET", "/api/pricing/product-performance/summary", "GET",
"pricing", "view", "pricing", "view",
@@ -999,6 +1005,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "view", "pricing", "view",
wrapV3(routes.GetProductPerformanceGroupedHandler(pgDB)), wrapV3(routes.GetProductPerformanceGroupedHandler(pgDB)),
) )
bindV3(r, pgDB,
"/api/pricing/product-performance/grouped/filter-options", "POST",
"pricing", "view",
wrapV3(routes.GetProductPerformanceGroupedFilterOptionsHandler(pgDB)),
)
bindV3(r, pgDB, bindV3(r, pgDB,
"/api/pricing/product-performance/sales-details", "GET", "/api/pricing/product-performance/sales-details", "GET",
"pricing", "view", "pricing", "view",
@@ -1379,6 +1390,7 @@ func main() {
// (e.g. SSH-tunnel PostgreSQL on 127.0.0.1:15432). // (e.g. SSH-tunnel PostgreSQL on 127.0.0.1:15432).
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
_ = godotenv.Overload(".env.local") _ = godotenv.Overload(".env.local")
_ = godotenv.Overload(".env.argebaggi.local")
} }
jwtSecret := os.Getenv("JWT_SECRET") jwtSecret := os.Getenv("JWT_SECRET")
@@ -1406,6 +1418,15 @@ func main() {
log.Fatal(err) 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() pgDB, err := db.ConnectPostgres()
if err != nil { 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) { func mountSPA(r *mux.Router) {
r.NotFoundHandler = http.HandlerFunc(spaIndex) r.NotFoundHandler = http.HandlerFunc(spaIndex)
r.HandleFunc("/", spaIndex).Methods(http.MethodGet) r.HandleFunc("/", spaIndex).Methods(http.MethodGet)
+174 -138
View File
@@ -1,68 +1,85 @@
package models package models
type ProductPerformanceRow struct { type ProductPerformanceRow struct {
KpiDate string `json:"kpi_date"` KpiDate string `json:"kpi_date"`
ProductCode string `json:"product_code"` ProductCode string `json:"product_code"`
ColorCode string `json:"color_code"` ColorCode string `json:"color_code"`
ColorDescription string `json:"color_description"` ColorDescription string `json:"color_description"`
YakaKodu string `json:"yaka_kodu"` YakaKodu string `json:"yaka_kodu"`
ItemDescription string `json:"item_description"` ItemDescription string `json:"item_description"`
Kategori string `json:"kategori"` Kategori string `json:"kategori"`
Seri string `json:"seri"` Seri string `json:"seri"`
YasGrubu string `json:"yas_grubu"` YasGrubu string `json:"yas_grubu"`
AskiliYan string `json:"askili_yan"` AskiliYan string `json:"askili_yan"`
UrunIlkGrubu string `json:"urun_ilk_grubu"` UrunIlkGrubu string `json:"urun_ilk_grubu"`
UrunAnaGrubu string `json:"urun_ana_grubu"` UrunAnaGrubu string `json:"urun_ana_grubu"`
UrunAltGrubu string `json:"urun_alt_grubu"` UrunAltGrubu string `json:"urun_alt_grubu"`
MarketKey string `json:"market_key"` MarketKey string `json:"market_key"`
StockQty float64 `json:"stock_qty"` StockQty float64 `json:"stock_qty"`
SalesQty30 float64 `json:"sales_qty_30d"` SalesQty30 float64 `json:"sales_qty_30d"`
SalesQty90 float64 `json:"sales_qty_90d"` SalesQty90 float64 `json:"sales_qty_90d"`
SalesQty180 float64 `json:"sales_qty_180d"` SalesQty180 float64 `json:"sales_qty_180d"`
SalesQty365 float64 `json:"sales_qty_365d"` SalesQty365 float64 `json:"sales_qty_365d"`
SalesQty730 float64 `json:"sales_qty_730d"` SalesQty730 float64 `json:"sales_qty_730d"`
SalesQtyTotal float64 `json:"sales_qty_total"` SalesQtyTotal float64 `json:"sales_qty_total"`
SalesUSD30 float64 `json:"sales_usd_30d"` SalesUSD30 float64 `json:"sales_usd_30d"`
SalesUSD90 float64 `json:"sales_usd_90d"` SalesUSD90 float64 `json:"sales_usd_90d"`
SalesUSD180 float64 `json:"sales_usd_180d"` SalesUSD180 float64 `json:"sales_usd_180d"`
SalesUSD365 float64 `json:"sales_usd_365d"` SalesUSD365 float64 `json:"sales_usd_365d"`
SalesUSDTotal float64 `json:"sales_usd_total"` SalesUSDTotal float64 `json:"sales_usd_total"`
AvgDailySales90 float64 `json:"avg_daily_sales_90d"` AvgDailySales90 float64 `json:"avg_daily_sales_90d"`
AvgDailySales180 float64 `json:"avg_daily_sales_180d"` AvgDailySales180 float64 `json:"avg_daily_sales_180d"`
AvgDailySales365 float64 `json:"avg_daily_sales_365d"` AvgDailySales365 float64 `json:"avg_daily_sales_365d"`
AvgDailySalesTotal float64 `json:"avg_daily_sales_total"` AvgDailySalesTotal float64 `json:"avg_daily_sales_total"`
StockDays90 float64 `json:"stock_days_90d"` AvgStock90 float64 `json:"avg_stock_90d"`
StockDays180 float64 `json:"stock_days_180d"` AvgStock180 float64 `json:"avg_stock_180d"`
StockDays365 float64 `json:"stock_days_365d"` AvgStock365 float64 `json:"avg_stock_365d"`
StockDaysTotal float64 `json:"stock_days_total"` AvgStockTotal float64 `json:"avg_stock_total"`
StockTurnover90 float64 `json:"stock_turnover_90d"` StockDays90 float64 `json:"stock_days_90d"`
StockTurnover180 float64 `json:"stock_turnover_180d"` StockDays180 float64 `json:"stock_days_180d"`
StockTurnover365 float64 `json:"stock_turnover_365d"` StockDays365 float64 `json:"stock_days_365d"`
StockTurnoverTotal float64 `json:"stock_turnover_total"` StockDaysTotal float64 `json:"stock_days_total"`
AvgPriceUSD90 float64 `json:"avg_price_usd_90d"` StockTurnover90 float64 `json:"stock_turnover_90d"`
AvgPriceUSD180 float64 `json:"avg_price_usd_180d"` StockTurnover180 float64 `json:"stock_turnover_180d"`
CostPriceUSD float64 `json:"cost_price_usd"` StockTurnover365 float64 `json:"stock_turnover_365d"`
BasePriceUSD float64 `json:"base_price_usd"` StockTurnoverTotal float64 `json:"stock_turnover_total"`
BasePriceTRY float64 `json:"base_price_try"` AvgPriceUSD90 float64 `json:"avg_price_usd_90d"`
GrossProfitUSD90 float64 `json:"gross_profit_usd_90d"` AvgPriceUSD180 float64 `json:"avg_price_usd_180d"`
GrossProfitUSD180 float64 `json:"gross_profit_usd_180d"` CostPriceUSD float64 `json:"cost_price_usd"`
GrossMargin90 float64 `json:"gross_margin_90d"` BasePriceUSD float64 `json:"base_price_usd"`
GrossMargin180 float64 `json:"gross_margin_180d"` BasePriceTRY float64 `json:"base_price_try"`
UnitProfitCost90 float64 `json:"unit_profit_cost_90d"` GrossProfitUSD90 float64 `json:"gross_profit_usd_90d"`
UnitProfitCost180 float64 `json:"unit_profit_cost_180d"` GrossProfitUSD180 float64 `json:"gross_profit_usd_180d"`
UnitProfitBase90 float64 `json:"unit_profit_base_90d"` GrossMargin90 float64 `json:"gross_margin_90d"`
UnitProfitBase180 float64 `json:"unit_profit_base_180d"` GrossMargin180 float64 `json:"gross_margin_180d"`
MarketCount90 int `json:"market_count_90d"` UnitProfitCost90 float64 `json:"unit_profit_cost_90d"`
CustomerCount90 int `json:"customer_count_90d"` UnitProfitCost180 float64 `json:"unit_profit_cost_180d"`
SalesIndex90 float64 `json:"sales_index_90d"` UnitProfitBase90 float64 `json:"unit_profit_base_90d"`
PriceIndex90 float64 `json:"price_index_90d"` UnitProfitBase180 float64 `json:"unit_profit_base_180d"`
MarginIndex90 float64 `json:"margin_index_90d"` MarketCount90 int `json:"market_count_90d"`
PerformanceScore float64 `json:"performance_score"` MarketCount180 int `json:"market_count_180d"`
PerformanceBucket string `json:"performance_bucket"` MarketCount365 int `json:"market_count_365d"`
Recommendation string `json:"recommendation"` MarketCountTotal int `json:"market_count_total"`
LastSaleDate string `json:"last_sale_date"` CustomerCount90 int `json:"customer_count_90d"`
LastRefNumber string `json:"last_ref_number"` CustomerCount180 int `json:"customer_count_180d"`
UpdatedAt string `json:"updated_at"` 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 { type ProductPerformanceSummary struct {
@@ -308,82 +325,101 @@ type ProductPerformanceCustomerRow struct {
} }
type ProductPerformanceSalesBreakdownRow struct { type ProductPerformanceSalesBreakdownRow struct {
Breakdown string `json:"breakdown"` Breakdown string `json:"breakdown"`
ProductCode string `json:"product_code"` ProductCode string `json:"product_code"`
ColorCode string `json:"color_code"` ColorCode string `json:"color_code"`
ColorDescription string `json:"color_description"` ColorDescription string `json:"color_description"`
YakaKodu string `json:"yaka_kodu"` YakaKodu string `json:"yaka_kodu"`
ItemDescription string `json:"item_description"` ItemDescription string `json:"item_description"`
Kategori string `json:"kategori"` Kategori string `json:"kategori"`
AskiliYan string `json:"askili_yan"` AskiliYan string `json:"askili_yan"`
UrunIlkGrubu string `json:"urun_ilk_grubu"` UrunIlkGrubu string `json:"urun_ilk_grubu"`
UrunAnaGrubu string `json:"urun_ana_grubu"` UrunAnaGrubu string `json:"urun_ana_grubu"`
UrunAltGrubu string `json:"urun_alt_grubu"` UrunAltGrubu string `json:"urun_alt_grubu"`
MarketKey string `json:"market_key"` MarketKey string `json:"market_key"`
Country string `json:"country"` Country string `json:"country"`
CustomerSegment string `json:"customer_segment"` CustomerSegment string `json:"customer_segment"`
CustomerCode string `json:"customer_code"` CustomerCode string `json:"customer_code"`
CustomerName string `json:"customer_name"` CustomerName string `json:"customer_name"`
ProductCount int `json:"product_count"` ProductCount int `json:"product_count"`
CustomerCount90 int `json:"customer_count_90d"` ProductGroupCount90 int `json:"product_group_count_90d"`
InvoiceCount90 int `json:"invoice_count_90d"` ProductGroupCount180 int `json:"product_group_count_180d"`
SalesQty90 float64 `json:"sales_qty_90d"` ProductGroupCount365 int `json:"product_group_count_365d"`
SalesUSD90 float64 `json:"sales_usd_90d"` ProductGroupCountTotal int `json:"product_group_count_total"`
AvgPriceUSD90 float64 `json:"avg_price_usd_90d"` MarketCount90 int `json:"market_count_90d"`
BasePriceUSD90 float64 `json:"base_price_usd_90d"` MarketCount180 int `json:"market_count_180d"`
CostPriceUSD90 float64 `json:"cost_price_usd_90d"` MarketCount365 int `json:"market_count_365d"`
GrossProfitBase90 float64 `json:"gross_profit_base_usd_90d"` MarketCountTotal int `json:"market_count_total"`
GrossProfitCost90 float64 `json:"gross_profit_cost_usd_90d"` CustomerCount90 int `json:"customer_count_90d"`
GrossMarginBase90 float64 `json:"gross_margin_base_90d"` InvoiceCount90 int `json:"invoice_count_90d"`
GrossMarginCost90 float64 `json:"gross_margin_cost_90d"` SalesQty90 float64 `json:"sales_qty_90d"`
CustomerCount180 int `json:"customer_count_180d"` SalesUSD90 float64 `json:"sales_usd_90d"`
InvoiceCount180 int `json:"invoice_count_180d"` AvgPriceUSD90 float64 `json:"avg_price_usd_90d"`
SalesQty180 float64 `json:"sales_qty_180d"` BasePriceUSD90 float64 `json:"base_price_usd_90d"`
SalesUSD180 float64 `json:"sales_usd_180d"` CostPriceUSD90 float64 `json:"cost_price_usd_90d"`
AvgPriceUSD180 float64 `json:"avg_price_usd_180d"` GrossProfitBase90 float64 `json:"gross_profit_base_usd_90d"`
BasePriceUSD180 float64 `json:"base_price_usd_180d"` GrossProfitCost90 float64 `json:"gross_profit_cost_usd_90d"`
CostPriceUSD180 float64 `json:"cost_price_usd_180d"` GrossMarginBase90 float64 `json:"gross_margin_base_90d"`
GrossProfitBase180 float64 `json:"gross_profit_base_usd_180d"` GrossMarginCost90 float64 `json:"gross_margin_cost_90d"`
GrossProfitCost180 float64 `json:"gross_profit_cost_usd_180d"` CustomerCount180 int `json:"customer_count_180d"`
GrossMarginBase180 float64 `json:"gross_margin_base_180d"` InvoiceCount180 int `json:"invoice_count_180d"`
GrossMarginCost180 float64 `json:"gross_margin_cost_180d"` SalesQty180 float64 `json:"sales_qty_180d"`
SalesQty365 float64 `json:"sales_qty_365d"` SalesUSD180 float64 `json:"sales_usd_180d"`
SalesUSD365 float64 `json:"sales_usd_365d"` AvgPriceUSD180 float64 `json:"avg_price_usd_180d"`
AvgPriceUSD365 float64 `json:"avg_price_usd_365d"` BasePriceUSD180 float64 `json:"base_price_usd_180d"`
BasePriceUSD365 float64 `json:"base_price_usd_365d"` CostPriceUSD180 float64 `json:"cost_price_usd_180d"`
CostPriceUSD365 float64 `json:"cost_price_usd_365d"` GrossProfitBase180 float64 `json:"gross_profit_base_usd_180d"`
GrossProfitBase365 float64 `json:"gross_profit_base_usd_365d"` GrossProfitCost180 float64 `json:"gross_profit_cost_usd_180d"`
GrossProfitCost365 float64 `json:"gross_profit_cost_usd_365d"` GrossMarginBase180 float64 `json:"gross_margin_base_180d"`
GrossMarginBase365 float64 `json:"gross_margin_base_365d"` GrossMarginCost180 float64 `json:"gross_margin_cost_180d"`
GrossMarginCost365 float64 `json:"gross_margin_cost_365d"` SalesQty365 float64 `json:"sales_qty_365d"`
CustomerCount365 int `json:"customer_count_365d"` SalesUSD365 float64 `json:"sales_usd_365d"`
InvoiceCount365 int `json:"invoice_count_365d"` AvgPriceUSD365 float64 `json:"avg_price_usd_365d"`
CustomerCountTotal int `json:"customer_count_total"` BasePriceUSD365 float64 `json:"base_price_usd_365d"`
InvoiceCountTotal int `json:"invoice_count_total"` CostPriceUSD365 float64 `json:"cost_price_usd_365d"`
SalesQtyTotal float64 `json:"sales_qty_total"` GrossProfitBase365 float64 `json:"gross_profit_base_usd_365d"`
SalesUSDTotal float64 `json:"sales_usd_total"` GrossProfitCost365 float64 `json:"gross_profit_cost_usd_365d"`
AvgPriceUSDTotal float64 `json:"avg_price_usd_total"` GrossMarginBase365 float64 `json:"gross_margin_base_365d"`
BasePriceUSDTotal float64 `json:"base_price_usd_total"` GrossMarginCost365 float64 `json:"gross_margin_cost_365d"`
CostPriceUSDTotal float64 `json:"cost_price_usd_total"` CustomerCount365 int `json:"customer_count_365d"`
GrossProfitBaseTotal float64 `json:"gross_profit_base_usd_total"` InvoiceCount365 int `json:"invoice_count_365d"`
GrossProfitCostTotal float64 `json:"gross_profit_cost_usd_total"` CustomerCountTotal int `json:"customer_count_total"`
GrossMarginBaseTotal float64 `json:"gross_margin_base_total"` InvoiceCountTotal int `json:"invoice_count_total"`
GrossMarginCostTotal float64 `json:"gross_margin_cost_total"` SalesQtyTotal float64 `json:"sales_qty_total"`
StockQty float64 `json:"stock_qty"` SalesUSDTotal float64 `json:"sales_usd_total"`
StockTurnover90 float64 `json:"stock_turnover_90d"` AvgPriceUSDTotal float64 `json:"avg_price_usd_total"`
StockTurnover180 float64 `json:"stock_turnover_180d"` BasePriceUSDTotal float64 `json:"base_price_usd_total"`
StockTurnover365 float64 `json:"stock_turnover_365d"` CostPriceUSDTotal float64 `json:"cost_price_usd_total"`
StockTurnoverTotal float64 `json:"stock_turnover_total"` GrossProfitBaseTotal float64 `json:"gross_profit_base_usd_total"`
HasCost bool `json:"has_cost"` GrossProfitCostTotal float64 `json:"gross_profit_cost_usd_total"`
SalesIndex90 float64 `json:"sales_index_90d"` GrossMarginBaseTotal float64 `json:"gross_margin_base_total"`
CustomerScore90 float64 `json:"customer_score_90d"` GrossMarginCostTotal float64 `json:"gross_margin_cost_total"`
CustomerScore180 float64 `json:"customer_score_180d"` StockQty float64 `json:"stock_qty"`
CustomerScore365 float64 `json:"customer_score_365d"` AvgStock90 float64 `json:"avg_stock_90d"`
CustomerScoreTotal float64 `json:"customer_score_total"` AvgStock180 float64 `json:"avg_stock_180d"`
PerformanceScore float64 `json:"performance_score"` AvgStock365 float64 `json:"avg_stock_365d"`
PerformanceBucket string `json:"performance_bucket"` AvgStockTotal float64 `json:"avg_stock_total"`
Recommendation string `json:"recommendation"` StockTurnover90 float64 `json:"stock_turnover_90d"`
LastSaleDate string `json:"last_sale_date"` 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 { type ProductPerformanceSalesDetailRow struct {
+19 -12
View File
@@ -1,22 +1,29 @@
package models package models
type ProductionNoCostProductRow struct { type ProductionNoCostProductRow struct {
UretimSekli string `json:"UretimSekli"` UretimSekli string `json:"UretimSekli"`
UrtSiparisNo string `json:"nUrtSiparisNo"` UrtSiparisNo string `json:"nUrtSiparisNo"`
IslemTarihi string `json:"dteIslemTarihi"` IslemTarihi string `json:"dteIslemTarihi"`
FirmaKodu string `json:"FirmaKodu"` FirmaKodu string `json:"FirmaKodu"`
FirmaAdi string `json:"FirmaAdi"` FirmaAdi string `json:"FirmaAdi"`
SonIsEmriVeren string `json:"SonIsEmriVeren"` SonIsEmriVeren string `json:"SonIsEmriVeren"`
ModelAdi string `json:"sAdi"` ModelAdi string `json:"sAdi"`
Kodu string `json:"sKodu"` Kodu string `json:"sKodu"`
SKullaniciAdi string `json:"sKullaniciAdi"` SKullaniciAdi string `json:"sKullaniciAdi"`
SKullaniciGunc string `json:"sKullaniciAdiGunc"` SKullaniciGunc string `json:"sKullaniciAdiGunc"`
MMiktarG float64 `json:"lMMiktar_G"` MMiktarG float64 `json:"lMMiktar_G"`
MModelKodu string `json:"sMModelKodu"` CeketDepoGiris float64 `json:"ceketDepoGiris"`
PantolonDepoGiris float64 `json:"pantolonDepoGiris"`
YelekDepoGiris float64 `json:"yelekDepoGiris"`
MModelKodu string `json:"sMModelKodu"`
} }
type ProductionHasCostProductRow struct { type ProductionHasCostProductRow struct {
UretimSekli string `json:"UretimSekli"` UretimSekli string `json:"UretimSekli"`
UrtSiparisNo string `json:"nUrtSiparisNo"`
CeketDepoGiris float64 `json:"ceketDepoGiris"`
PantolonDepoGiris float64 `json:"pantolonDepoGiris"`
YelekDepoGiris float64 `json:"yelekDepoGiris"`
NOnMLNo string `json:"nOnMLNo"` NOnMLNo string `json:"nOnMLNo"`
UrunKodu string `json:"UrunKodu"` UrunKodu string `json:"UrunKodu"`
UrunAdi string `json:"UrunAdi"` UrunAdi string `json:"UrunAdi"`
+94 -2
View File
@@ -26,6 +26,12 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
deltaHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_DELTA_HHMM", "02:00") deltaHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_DELTA_HHMM", "02:00")
deltaTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_TIMEOUT_HOURS", 6, 1, 24) deltaTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_TIMEOUT_HOURS", 6, 1, 24)
runOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_DELTA_RUN_ON_STARTUP", false) 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 var running int32
runDelta := func(reason string) { runDelta := func(reason string) {
@@ -49,12 +55,40 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err) log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err)
return return
} }
log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d duration_ms=%d", 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.DurationMS) 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", log.Printf("[ProductPerformanceJob] scheduled daily_delta=%s lookback_days=%d run_delta_on_startup=%t",
deltaHHMM, deltaDays, runOnStartup) 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() { go func() {
if runOnStartup { if runOnStartup {
@@ -69,6 +103,21 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
runDelta("daily-delta") 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 { func productPerformanceNextDaily(now time.Time, hhmm string) time.Time {
@@ -80,6 +129,16 @@ func productPerformanceNextDaily(now time.Time, hhmm string) time.Time {
return next 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) { func productPerformanceParseHHMM(raw string, fallbackHour, fallbackMinute int) (int, int) {
parts := strings.Split(strings.TrimSpace(raw), ":") parts := strings.Split(strings.TrimSpace(raw), ":")
if len(parts) != 2 { if len(parts) != 2 {
@@ -120,3 +179,36 @@ func productPerformanceEnvBool(name string, fallback bool) bool {
} }
return raw == "1" || raw == "true" || raw == "on" || raw == "yes" 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
}
}
+3326 -315
View File
@@ -8,7 +8,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"math"
"os"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -39,16 +42,19 @@ type ProductPerformanceRefreshRequest struct {
} }
type ProductPerformanceRefreshResult struct { type ProductPerformanceRefreshResult struct {
Mode string `json:"mode"` Mode string `json:"mode"`
Stage string `json:"stage"` Stage string `json:"stage"`
StartDate string `json:"start_date"` StartDate string `json:"start_date"`
EndDate string `json:"end_date"` EndDate string `json:"end_date"`
SalesRows int `json:"sales_rows"` SalesRows int `json:"sales_rows"`
StockRows int `json:"stock_rows"` StockRows int `json:"stock_rows"`
KpiRows int `json:"kpi_rows"` KpiRows int `json:"kpi_rows"`
DurationMS int64 `json:"duration_ms"` SnapshotRows int `json:"snapshot_rows"`
DurationMS int64 `json:"duration_ms"`
} }
type productPerformanceSnapshotBypassKey struct{}
func productPerformanceStockQuery() string { func productPerformanceStockQuery() string {
return ` return `
;WITH ActiveWarehouses AS ( ;WITH ActiveWarehouses AS (
@@ -367,10 +373,10 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_sales_daily (
UPDATE mk_product_performance_sales_daily UPDATE mk_product_performance_sales_daily
SET urun_ilk_grubu = '', updated_at = now() SET urun_ilk_grubu = '', updated_at = now()
WHERE btrim(urun_ilk_grubu) = '-' 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 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 UPDATE mk_product_performance_sales_daily
SET askili_yan = '', updated_at = now() 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 UPDATE mk_product_performance_price_dim
SET SET
cost_price_usd = GREATEST(cost_price_usd, base_price_usd), cost_price_usd = LEAST(cost_price_usd, base_price_usd),
base_price_usd = LEAST(cost_price_usd, base_price_usd), base_price_usd = GREATEST(cost_price_usd, base_price_usd),
updated_at = now() updated_at = now()
WHERE cost_price_usd > 0 WHERE cost_price_usd > 0
AND base_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 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 ( CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
kpi_date DATE NOT NULL, 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, sales_usd_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0, avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0, 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_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0, stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_price_usd_90d NUMERIC(18,6) NOT NULL DEFAULT 0, avg_price_usd_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
@@ -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_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
unit_profit_base_180d 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_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_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_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, price_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
margin_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, 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 UPDATE mk_product_performance_kpi_daily
SET urun_ilk_grubu = '', updated_at = now() SET urun_ilk_grubu = '', updated_at = now()
WHERE btrim(urun_ilk_grubu) = '-' 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 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 UPDATE mk_product_performance_kpi_daily
SET SET
cost_price_usd = GREATEST(cost_price_usd, base_price_usd), cost_price_usd = LEAST(cost_price_usd, base_price_usd),
base_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) * 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) * GREATEST(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) * GREATEST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_90d,0) END, 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) * GREATEST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_180d,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)) - GREATEST(cost_price_usd, base_price_usd) 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)) - 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)) - 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)) - 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)) - 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)) - GREATEST(cost_price_usd, base_price_usd) END,
updated_at = now() updated_at = now()
WHERE cost_price_usd > 0 WHERE cost_price_usd > 0
AND base_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 UPDATE mk_product_performance_kpi_daily
SET askili_yan = '', updated_at = now() 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_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_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_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_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_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`, `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_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 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_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 { for _, stmt := range stmts {
if _, err := pg.Exec(stmt); err != nil { if _, err := pg.Exec(stmt); err != nil {
@@ -570,6 +649,523 @@ WHERE btrim(askili_yan) = '-'`,
return nil 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) { func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) {
started := time.Now() started := time.Now()
stage := productPerformanceRefreshStage(req.Stage) stage := productPerformanceRefreshStage(req.Stage)
@@ -689,6 +1285,7 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo
} }
var kpiRows int var kpiRows int
var snapshotRows int
if shouldRun("kpi") { if shouldRun("kpi") {
if err := runStage("kpi", func(tx *sql.Tx) error { if err := runStage("kpi", func(tx *sql.Tx) error {
log.Printf("[ProductPerformanceRefresh] kpi rebuild start") log.Printf("[ProductPerformanceRefresh] kpi rebuild start")
@@ -702,20 +1299,28 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo
}); err != nil { }); err != nil {
return ProductPerformanceRefreshResult{}, err 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 { } else {
log.Printf("[ProductPerformanceRefresh] kpi skipped stage=%s", stage) log.Printf("[ProductPerformanceRefresh] kpi skipped stage=%s", stage)
} }
log.Printf("[ProductPerformanceRefresh] refresh done total_elapsed=%s", time.Since(started).Round(time.Second)) log.Printf("[ProductPerformanceRefresh] refresh done total_elapsed=%s", time.Since(started).Round(time.Second))
return ProductPerformanceRefreshResult{ return ProductPerformanceRefreshResult{
Mode: mode, Mode: mode,
Stage: stage, Stage: stage,
StartDate: req.StartDate.Format("2006-01-02"), StartDate: req.StartDate.Format("2006-01-02"),
EndDate: req.EndDate.Format("2006-01-02"), EndDate: req.EndDate.Format("2006-01-02"),
SalesRows: salesRows, SalesRows: salesRows,
StockRows: stockRows, StockRows: stockRows,
KpiRows: kpiRows, KpiRows: kpiRows,
DurationMS: time.Since(started).Milliseconds(), SnapshotRows: snapshotRows,
DurationMS: time.Since(started).Milliseconds(),
}, nil }, nil
} }
@@ -1168,6 +1773,14 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc
if page <= 0 { if page <= 0 {
page = 1 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) 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 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 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_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, 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_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_days_90d, stock_days_180d, stock_days_365d, stock_days_total,
stock_turnover_90d, stock_turnover_180d, stock_turnover_365d, stock_turnover_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, 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, 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, price_index_90d, margin_index_90d, performance_score, performance_bucket,
recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number, recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number,
to_char(updated_at,'YYYY-MM-DD HH24:MI:SS') to_char(updated_at,'YYYY-MM-DD HH24:MI:SS')
@@ -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.SalesQty30, &r.SalesQty90, &r.SalesQty180, &r.SalesQty365, &r.SalesQty730, &r.SalesQtyTotal,
&r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.SalesUSDTotal, &r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.SalesUSDTotal,
&r.AvgDailySales90, &r.AvgDailySales180, &r.AvgDailySales365, &r.AvgDailySalesTotal, &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.StockDays90, &r.StockDays180, &r.StockDays365, &r.StockDaysTotal,
&r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal,
&r.AvgPriceUSD90, &r.AvgPriceUSD180, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY, &r.AvgPriceUSD90, &r.AvgPriceUSD180, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY,
&r.GrossProfitUSD90, &r.GrossProfitUSD180, &r.GrossMargin90, &r.GrossMargin180, &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.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket,
&r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt, &r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt,
); err != nil { ); err != nil {
@@ -1230,13 +1851,125 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
for i := range out { for i := range out {
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)] out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
} }
applyProductPerformanceProductRowScores(out)
return out, total, nil 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) { func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) {
if err := EnsureProductPerformanceTables(pg); err != nil { if err := EnsureProductPerformanceTables(pg); err != nil {
return models.ProductPerformanceSummary{}, err 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 var s models.ProductPerformanceSummary
err := pg.QueryRowContext(ctx, ` err := pg.QueryRowContext(ctx, `
WITH Latest AS ( WITH Latest AS (
@@ -1247,7 +1980,7 @@ KPI AS (
SELECT * SELECT *
FROM mk_product_performance_kpi_daily FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT kpi_date FROM Latest) 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 ( VariantStock AS (
SELECT SELECT
@@ -1274,7 +2007,7 @@ Sales90 AS (
COUNT(DISTINCT NULLIF(customer_code, '-')) AS customer_count_90d COUNT(DISTINCT NULLIF(customer_code, '-')) AS customer_count_90d
FROM mk_product_performance_sales_daily FROM mk_product_performance_sales_daily
WHERE sales_date BETWEEN (SELECT kpi_date FROM Latest) - INTERVAL '89 days' AND (SELECT kpi_date FROM Latest) 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 SELECT
COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''), 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 { } else if limit > 50000 {
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, ` rows, err := pg.QueryContext(ctx, `
WITH Bounds AS ( WITH Bounds AS (
SELECT SELECT
@@ -1340,11 +2079,11 @@ SalesAgg AS (
MAX(last_ref_number) AS last_ref_number, MAX(last_ref_number) AS last_ref_number,
COALESCE(SUM(sales_qty),0) AS sales_qty_total, COALESCE(SUM(sales_qty),0) AS sales_qty_total,
COALESCE(SUM(sales_usd),0) AS sales_usd_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 COALESCE(SUM(invoice_count),0)::integer AS invoice_count_total
FROM mk_product_performance_sales_daily, Bounds FROM mk_product_performance_sales_daily, Bounds
WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end 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 GROUP BY product_code, color_code, yaka_kodu, market_key
), ),
StockAgg AS ( StockAgg AS (
@@ -1396,7 +2135,7 @@ Dim AS (
1 AS src_rank 1 AS src_rank
FROM mk_product_performance_kpi_daily FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) WHERE 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 UNION ALL
SELECT SELECT
product_code, product_code,
@@ -1412,7 +2151,7 @@ Dim AS (
MAX(urun_alt_grubu) AS urun_alt_grubu, MAX(urun_alt_grubu) AS urun_alt_grubu,
2 AS src_rank 2 AS src_rank
FROM mk_product_performance_sales_daily 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 GROUP BY product_code, color_code, yaka_kodu
) x ) x
ORDER BY product_code, color_code, yaka_kodu, src_rank 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.color_code = s.color_code
AND d.yaka_kodu = s.yaka_kodu AND d.yaka_kodu = s.yaka_kodu
WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate) 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 ( AND NOT EXISTS (
SELECT 1 SELECT 1
FROM SalesAgg a FROM SalesAgg a
@@ -1464,11 +2203,11 @@ Spread AS (
product_code, product_code,
color_code, color_code,
yaka_kodu, yaka_kodu,
COUNT(DISTINCT NULLIF(market_key, 'STOK'))::integer AS market_count_total, COUNT(DISTINCT NULLIF(market_key, 'STOK')) FILTER (WHERE COALESCE(sales_usd,0) > 0)::integer AS market_count_total,
COUNT(DISTINCT NULLIF(customer_code, '-'))::integer AS customer_count_total_all 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 FROM mk_product_performance_sales_daily, Bounds
WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end 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 GROUP BY product_code, color_code, yaka_kodu
), ),
Scored AS ( 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_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.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, 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, CASE WHEN b.market_key <> 'STOK' AND b.sales_usd_total > 0 THEN 1 ELSE 0 END AS market_count_total,
COALESCE(sp.customer_count_total_all, b.customer_count_total) AS customer_count_total, b.customer_count_total AS customer_count_total,
b.invoice_count_total, b.invoice_count_total,
CASE CASE
WHEN AVG(b.sales_qty_total) OVER (PARTITION BY b.market_key, b.kategori, b.urun_ana_grubu) <= 0 THEN 0 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 AND sales_index_total >= 1 THEN 'STOKSUZ_TALEP'
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'STOK_RISKI' WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'STOK_RISKI'
WHEN gross_margin_total < 0 THEN 'FIYAT_BASKISI' 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' WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'FIYAT_FIRSATI'
ELSE 'TAKIP' ELSE 'TAKIP'
END AS performance_bucket, 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 '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 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 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.' 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.' ELSE 'Izleme ve piyasa bazli aksiyon.'
END AS recommendation, END AS recommendation,
@@ -1600,9 +2339,28 @@ LIMIT $1
for i := range out { for i := range out {
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)] out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
} }
applyProductPerformanceGeneralRowScores(out)
return out, nil 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) { func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderAnalysisRow, error) {
if db.MssqlDB == nil { if db.MssqlDB == nil {
return nil, fmt.Errorf("mssql db 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 { } else if limit > 50000 {
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, ` rows, err := db.MssqlDB.QueryContext(ctx, `
WITH OpenOrderLines AS ( WITH OpenOrderLines AS (
@@ -1917,6 +2680,11 @@ func ListProductPerformanceOrderGroups(ctx context.Context, pg *sql.DB, breakdow
} else if limit > 50000 { } else if limit > 50000 {
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, ` rows, err := db.MssqlDB.QueryContext(ctx, `
WITH OpenOrderLines AS ( WITH OpenOrderLines AS (
@@ -2153,6 +2921,11 @@ func ListProductPerformanceOrderProductCustomers(ctx context.Context, pg *sql.DB
} else if limit > 50000 { } else if limit > 50000 {
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, ` rows, err := db.MssqlDB.QueryContext(ctx, `
WITH OpenOrderLines AS ( WITH OpenOrderLines AS (
SELECT SELECT
@@ -2291,6 +3064,11 @@ func ListProductPerformanceOrderMarketDetails(ctx context.Context, pg *sql.DB, l
} else if limit > 50000 { } else if limit > 50000 {
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, ` rows, err := db.MssqlDB.QueryContext(ctx, `
WITH OpenOrderLines AS ( WITH OpenOrderLines AS (
SELECT SELECT
@@ -2414,6 +3192,11 @@ func ListProductPerformanceMarkets(ctx context.Context, pg *sql.DB, limit int) (
} else if limit > 50000 { } else if limit > 50000 {
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, ` rows, err := pg.QueryContext(ctx, `
SELECT SELECT
market_key, market_key,
@@ -2437,7 +3220,7 @@ SELECT
COALESCE(AVG(NULLIF(stock_days_90d,0)),0) AS avg_stock_days_90d COALESCE(AVG(NULLIF(stock_days_90d,0)),0) AS avg_stock_days_90d
FROM mk_product_performance_kpi_daily FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) WHERE 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 GROUP BY market_key
ORDER BY risk_stock_cost_value_usd DESC, stock_cost_value_usd DESC, sales_usd_90d DESC ORDER BY risk_stock_cost_value_usd DESC, stock_cost_value_usd DESC, sales_usd_90d DESC
LIMIT $1 LIMIT $1
@@ -2472,6 +3255,11 @@ func ListProductPerformanceCountries(ctx context.Context, pg *sql.DB, limit int)
} else if limit > 50000 { } else if limit > 50000 {
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, ` rows, err := pg.QueryContext(ctx, `
SELECT SELECT
customer_country, customer_country,
@@ -2487,13 +3275,13 @@ SELECT
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) 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) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
END AS avg_price_usd_90d, 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(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_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 COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d
FROM mk_product_performance_sales_daily FROM mk_product_performance_sales_daily
WHERE sales_date >= current_date - INTERVAL '364 days' 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 GROUP BY customer_country, customer_segment, market_key
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
LIMIT $1 LIMIT $1
@@ -2549,6 +3337,11 @@ func ListProductPerformanceCustomers(ctx context.Context, pg *sql.DB, breakdown
selectMarket = "market_key" selectMarket = "market_key"
groupCols = append(groupCols, "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(` query := fmt.Sprintf(`
SELECT SELECT
@@ -2573,7 +3366,7 @@ SELECT
FROM mk_product_performance_sales_daily FROM mk_product_performance_sales_daily
WHERE sales_date >= current_date - INTERVAL '364 days' WHERE sales_date >= current_date - INTERVAL '364 days'
AND COALESCE(customer_code,'') <> '' 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 GROUP BY %s
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
LIMIT $1 LIMIT $1
@@ -2616,7 +3409,7 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
"item_description": "MAX(s.item_description)", "item_description": "MAX(s.item_description)",
"kategori": "COALESCE(MAX(s.kategori),'')", "kategori": "COALESCE(MAX(s.kategori),'')",
"askili_yan": "COALESCE(MAX(CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END),'')", "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_ana_grubu": "COALESCE(MAX(s.urun_ana_grubu),'')",
"urun_alt_grubu": "MAX(s.urun_alt_grubu)", "urun_alt_grubu": "MAX(s.urun_alt_grubu)",
"market_key": "s.market_key", "market_key": "s.market_key",
@@ -2643,7 +3436,7 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
selected["market_key"] = true selected["market_key"] = true
selected["customer_code"] = true selected["customer_code"] = true
selected["customer_name"] = 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": case "product_country_segment_market_customer":
selected["product_code"] = true selected["product_code"] = true
selected["item_description"] = true selected["item_description"] = true
@@ -2706,7 +3499,14 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
selected["market_key"] = true selected["market_key"] = true
selected["customer_code"] = true selected["customer_code"] = true
selected["customer_name"] = 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 { 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(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan,
CASE CASE
WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN '' 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,'') ELSE COALESCE(urun_ilk_grubu,'')
END AS urun_ilk_grubu, END AS urun_ilk_grubu,
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu, COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
urun_alt_grubu urun_alt_grubu
FROM mk_product_performance_kpi_daily FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) WHERE 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 ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
), ),
StockAgg AS ( StockAgg AS (
@@ -2786,6 +3586,19 @@ StockTotalStart AS (
WHERE stock_date <= DATE '2022-01-01' WHERE stock_date <= DATE '2022-01-01'
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC 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 ( Agg AS (
SELECT SELECT
$2::text AS breakdown, $2::text AS breakdown,
@@ -2804,12 +3617,20 @@ Agg AS (
%s, %s,
%s, %s,
COUNT(DISTINCT product_code) AS product_count, 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(stock_qty),0) AS stock_qty,
COALESCE(MAX(avg_stock_90d),0) AS avg_stock_90d, COALESCE(MAX(avg_stock_90d),0) AS avg_stock_90d,
COALESCE(MAX(avg_stock_180d),0) AS avg_stock_180d, COALESCE(MAX(avg_stock_180d),0) AS avg_stock_180d,
COALESCE(MAX(avg_stock_365d),0) AS avg_stock_365d, COALESCE(MAX(avg_stock_365d),0) AS avg_stock_365d,
COALESCE(MAX(avg_stock_total),0) AS avg_stock_total, 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(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_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, 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) 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) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
END AS cost_price_usd_90d, 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(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_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, 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, 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_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, 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, 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 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) 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) 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) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
END AS cost_price_usd_365d, 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(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_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, 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.base_price_usd,0) AS base_price_usd,
COALESCE(pd.cost_price_usd,0) AS cost_price_usd, COALESCE(pd.cost_price_usd,0) AS cost_price_usd,
COALESCE(st.stock_qty,0) AS stock_qty, 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(s90.stock_qty, st.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(s180.stock_qty, st.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(s365.stock_qty, st.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(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 FROM mk_product_performance_sales_daily s
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code
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 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 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 LATERAL (
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 SELECT x.stock_qty
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 FROM mk_product_performance_stock_daily x
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 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 ) s
WHERE sales_date >= DATE '2022-01-01' 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 GROUP BY %s
), ),
Enriched AS ( Enriched AS (
@@ -2944,6 +3802,14 @@ SELECT
customer_code, customer_code,
customer_name, customer_name,
product_count, 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, stock_qty,
customer_count_90d, customer_count_90d,
invoice_count_90d, invoice_count_90d,
@@ -2989,55 +3855,55 @@ SELECT
gross_profit_cost_usd_total, gross_profit_cost_usd_total,
gross_margin_base_total, gross_margin_base_total,
gross_margin_cost_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, avg_stock_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_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_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, has_cost,
sales_index_90d, 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(( ROUND((
LEAST(35, sales_usd_90d / 1000) LEAST(35, sales_usd_90d / 25000 * 35)
+ LEAST(25, GREATEST(gross_margin_cost_90d,0) * 55) + LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.45 * 30)
+ LEAST(20, invoice_count_90d * 2) + LEAST(20, product_group_count_90d / 8.0 * 20)
+ LEAST(10, sales_qty_90d / 10) + LEAST(15, sales_qty_90d / 500 * 15)
+ LEAST(10, product_count)
)::numeric, 4) )::numeric, 4)
END AS customer_score_90d, 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(( ROUND((
LEAST(35, sales_usd_180d / 1000) LEAST(35, sales_usd_180d / 50000 * 35)
+ LEAST(25, GREATEST(gross_margin_cost_180d,0) * 55) + LEAST(30, GREATEST(gross_margin_cost_180d,0) / 0.45 * 30)
+ LEAST(20, invoice_count_180d * 2) + LEAST(20, product_group_count_180d / 8.0 * 20)
+ LEAST(10, sales_qty_180d / 10) + LEAST(15, sales_qty_180d / 1000 * 15)
+ LEAST(10, product_count)
)::numeric, 4) )::numeric, 4)
END AS customer_score_180d, 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(( ROUND((
LEAST(35, sales_usd_365d / 1000) LEAST(35, sales_usd_365d / 100000 * 35)
+ LEAST(25, GREATEST(gross_margin_cost_365d,0) * 55) + LEAST(30, GREATEST(gross_margin_cost_365d,0) / 0.45 * 30)
+ LEAST(20, invoice_count_365d * 2) + LEAST(20, product_group_count_365d / 8.0 * 20)
+ LEAST(10, sales_qty_365d / 10) + LEAST(15, sales_qty_365d / 2000 * 15)
+ LEAST(10, product_count)
)::numeric, 4) )::numeric, 4)
END AS customer_score_365d, 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(( ROUND((
LEAST(35, sales_usd_total / 1000) LEAST(35, sales_usd_total / 300000 * 35)
+ LEAST(25, GREATEST(gross_margin_cost_total,0) * 55) + LEAST(30, GREATEST(gross_margin_cost_total,0) / 0.45 * 30)
+ LEAST(20, invoice_count_total * 2) + LEAST(20, product_group_count_total / 8.0 * 20)
+ LEAST(10, sales_qty_total / 10) + LEAST(15, sales_qty_total / 6000 * 15)
+ LEAST(10, product_count)
)::numeric, 4) )::numeric, 4)
END AS customer_score_total, 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( ROUND(
LEAST(35, sales_index_90d * 18) LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.45 * 30)
+ LEAST(30, GREATEST(gross_margin_cost_90d,0) * 60) + 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(15, invoice_count_90d * 1.5) + LEAST(20, sales_usd_90d / 25000 * 20)
+ LEAST(10, sales_qty_90d / 10) + LEAST(5, market_count_90d / 3.0 * 5)
+ LEAST(10, (CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END) * 5), + LEAST(25, customer_count_90d / 8.0 * 25),
4 4
) )
END AS performance_score, END AS performance_score,
@@ -3050,8 +3916,8 @@ SELECT
ELSE 'TAKIP' ELSE 'TAKIP'
END AS performance_bucket, END AS performance_bucket,
CASE CASE
WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'Güçlü satış kırılımı' 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 gün zayıf, kontrol et' WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'Son 90 gun zayif, kontrol et'
ELSE 'Takip' ELSE 'Takip'
END AS recommendation, END AS recommendation,
last_sale_date last_sale_date
@@ -3089,7 +3955,9 @@ LIMIT $1
&r.Breakdown, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.Breakdown, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
&r.Kategori, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.Kategori, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu,
&r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &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.AvgPriceUSD90, &r.BasePriceUSD90, &r.CostPriceUSD90, &r.GrossProfitBase90,
&r.GrossProfitCost90, &r.GrossMarginBase90, &r.GrossMarginCost90, &r.CustomerCount180, &r.GrossProfitCost90, &r.GrossMarginBase90, &r.GrossMarginCost90, &r.CustomerCount180,
&r.InvoiceCount180, &r.SalesQty180, &r.SalesUSD180, &r.AvgPriceUSD180, &r.BasePriceUSD180, &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.GrossMarginCost365, &r.CustomerCount365, &r.InvoiceCount365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal,
&r.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal, &r.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal,
&r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal, &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.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.HasCost,
&r.SalesIndex90, &r.CustomerScore90, &r.CustomerScore180, &r.CustomerScore365, &r.CustomerScoreTotal, &r.PerformanceScore, &r.SalesIndex90, &r.CustomerScore90, &r.CustomerScore180, &r.CustomerScore365, &r.CustomerScoreTotal, &r.PerformanceScore,
&r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate, &r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate,
@@ -3115,17 +3984,76 @@ LIMIT $1
for i := range out { for i := range out {
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)] out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
} }
applyProductPerformanceSalesBreakdownScores(out)
return out, nil 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 { type ProductPerformanceGroupedRequest struct {
Mode string Mode string
GroupLevels []string GroupLevels []string
ExpandedKeys map[string]bool ExpandedKeys map[string]bool
Limit int 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) { 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 { if req.Limit <= 0 || req.Limit > 50000 {
req.Limit = 50000 req.Limit = 50000
} }
@@ -3133,8 +4061,11 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
if len(levels) == 0 { if len(levels) == 0 {
levels = defaultProductPerformanceGroupLevels(req.Mode) 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 return out, err
} }
@@ -3142,36 +4073,404 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
if err != nil { if err != nil {
return nil, err return nil, err
} }
sourceRows = filterProductPerformanceGroupedRows(sourceRows, productPerformanceGroupedEffectiveFilters(req))
out := make([]map[string]any, 0, len(sourceRows)) 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 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 { type productPerformanceSQLGroupFilter struct {
Field string Field string
Value string Value string
Values []string
} }
func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) { func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) {
mode := strings.TrimSpace(req.Mode) mode := strings.TrimSpace(req.Mode)
if mode != "products" && mode != "idle" { if mode != "products" && mode != "product_detail" && mode != "idle" {
return nil, false, nil return nil, false, nil
} }
if err := EnsureProductPerformanceTables(pg); err != nil { if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, true, err return nil, true, err
} }
out := make([]map[string]any, 0, 512) out := make([]map[string]any, 0, 512)
err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, 0, []string{"tab:" + mode}, nil, req.ExpandedKeys, req.Limit) filters := productPerformanceGroupedBaseFilters(req)
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 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 { func productPerformanceGroupedBaseFilters(req ProductPerformanceGroupedRequest) []productPerformanceSQLGroupFilter {
if level >= len(levels) { effectiveFilters := productPerformanceGroupedEffectiveFilters(req)
rows, err := queryProductPerformanceSQLLeafRows(ctx, pg, mode, filters, limit) filters := make([]productPerformanceSQLGroupFilter, 0, len(effectiveFilters))
if err != nil { for field, values := range effectiveFilters {
return err cleanValues := cleanProductPerformanceFilterValues(values)
if len(cleanValues) == 0 {
continue
} }
*out = append(*out, rows...) filters = append(filters, productPerformanceSQLGroupFilter{Field: field, Values: cleanValues})
}
return filters
}
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
}
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 return nil
} }
@@ -3184,7 +4483,7 @@ func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out
value := stringFromMap(row, "group_value") value := stringFromMap(row, "group_value")
if shouldSkipProductPerformanceGroupValue(field, value) { if shouldSkipProductPerformanceGroupValue(field, value) {
nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: 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 return err
} }
continue continue
@@ -3199,10 +4498,11 @@ func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out
row["label"] = value row["label"] = value
row["__group"] = true row["__group"] = true
row[field] = value row[field] = value
deriveProductPerformanceGroupMetrics(row, field)
*out = append(*out, row) *out = append(*out, row)
if expandedKeys[key] { if expandedKeys[key] || level <= expandThroughLevel {
nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value}) 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 return err
} }
} }
@@ -3220,62 +4520,83 @@ func queryProductPerformanceSQLGroupRows(ctx context.Context, pg *sql.DB, mode,
return nil, err return nil, err
} }
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(` query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
SELECT jsonb_build_object( SELECT (
'group_value', group_value, jsonb_build_object(
'label', group_value, 'group_value', group_value,
'count', row_count, 'label', group_value,
'recommendation', row_count::text || ' satir', 'period_start', '2022-01-01',
'image_product_code', image_product_code, 'period_end', COALESCE(to_char((SELECT kpi_date FROM LatestKPIDate),'YYYY-MM-DD'), ''),
'image_color_code', image_color_code, 'count', row_count,
'image_yaka_kodu', image_yaka_kodu, 'recommendation', CASE WHEN recommendation <> '' THEN recommendation ELSE row_count::text || ' satir' END,
'product_code', CASE WHEN $%d = 'product_code' THEN group_value ELSE product_code END, 'image_product_code', image_product_code,
'color_code', CASE WHEN $%d = 'color_code' THEN group_value ELSE color_code END, 'image_color_code', image_color_code,
'yaka_kodu', CASE WHEN $%d = 'yaka_kodu' THEN group_value ELSE yaka_kodu END, 'image_yaka_kodu', image_yaka_kodu,
'item_description', item_description, 'product_code', CASE WHEN $%d = 'product_code' THEN group_value ELSE '' END,
'kategori', CASE WHEN $%d = 'kategori' THEN group_value ELSE kategori END, 'color_code', CASE WHEN $%d = 'color_code' THEN group_value ELSE '' END,
'askili_yan', CASE WHEN $%d = 'askili_yan' THEN group_value ELSE askili_yan END, 'yaka_kodu', CASE WHEN $%d = 'yaka_kodu' THEN group_value ELSE '' END,
'urun_ilk_grubu', CASE WHEN $%d = 'urun_ilk_grubu' THEN group_value ELSE urun_ilk_grubu END, 'item_description', CASE WHEN $%d = 'item_description' THEN group_value ELSE '' END,
'urun_ana_grubu', CASE WHEN $%d = 'urun_ana_grubu' THEN group_value ELSE urun_ana_grubu END, 'kategori', CASE WHEN $%d = 'kategori' THEN group_value ELSE '' END,
'urun_alt_grubu', CASE WHEN $%d = 'urun_alt_grubu' THEN group_value ELSE urun_alt_grubu END, 'askili_yan', CASE WHEN $%d = 'askili_yan' THEN group_value ELSE '' END,
'market_key', CASE WHEN $%d = 'market_key' THEN group_value ELSE market_key END, 'urun_ilk_grubu', CASE WHEN $%d = 'urun_ilk_grubu' THEN group_value ELSE '' END,
'stock_qty', stock_qty, 'urun_ana_grubu', CASE WHEN $%d = 'urun_ana_grubu' THEN group_value ELSE '' END,
'sales_qty_90d', sales_qty_90d, 'urun_alt_grubu', CASE WHEN $%d = 'urun_alt_grubu' THEN group_value ELSE '' END,
'sales_qty_180d', sales_qty_180d, 'market_key', CASE WHEN $%d = 'market_key' THEN group_value ELSE '' END
'sales_qty_365d', sales_qty_365d, )
'sales_qty_total', sales_qty_total, || jsonb_build_object(
'sales_usd_90d', sales_usd_90d, 'stock_qty', stock_qty,
'sales_usd_180d', sales_usd_180d, 'avg_stock_90d', avg_stock_90d,
'sales_usd_365d', sales_usd_365d, 'avg_stock_180d', avg_stock_180d,
'sales_usd_total', sales_usd_total, 'avg_stock_365d', avg_stock_365d,
'avg_price_usd_90d', avg_price_usd_90d, 'avg_stock_total', avg_stock_total,
'avg_price_usd_180d', avg_price_usd_180d, 'sales_qty_90d', sales_qty_90d,
'cost_price_usd', cost_price_usd, 'sales_qty_180d', sales_qty_180d,
'base_price_usd', base_price_usd, 'sales_qty_365d', sales_qty_365d,
'base_price_try', base_price_try, 'sales_qty_total', sales_qty_total,
'gross_profit_usd_90d', gross_profit_usd_90d, 'sales_usd_90d', sales_usd_90d,
'gross_profit_usd_180d', gross_profit_usd_180d, 'sales_usd_180d', sales_usd_180d,
'gross_margin_90d', gross_margin_90d, 'sales_usd_365d', sales_usd_365d,
'gross_margin_180d', gross_margin_180d, 'sales_usd_total', sales_usd_total,
'unit_profit_cost_90d', unit_profit_cost_90d, 'avg_price_usd_90d', avg_price_usd_90d,
'unit_profit_cost_180d', unit_profit_cost_180d, 'avg_price_usd_180d', avg_price_usd_180d,
'unit_profit_base_90d', unit_profit_base_90d, 'cost_price_usd', cost_price_usd,
'unit_profit_base_180d', unit_profit_base_180d, 'base_price_usd', base_price_usd,
'market_count_90d', market_count_90d, 'base_price_try', base_price_try,
'customer_count_90d', customer_count_90d, 'gross_profit_usd_90d', gross_profit_usd_90d,
'sales_index_90d', sales_index_90d, 'gross_profit_usd_180d', gross_profit_usd_180d,
'price_index_90d', price_index_90d, 'gross_margin_90d', gross_margin_90d,
'margin_index_90d', margin_index_90d, 'gross_margin_180d', gross_margin_180d
'performance_score', performance_score, )
'performance_bucket', performance_bucket, || jsonb_build_object(
'idle_cost_usd', idle_cost_usd, 'unit_profit_cost_90d', unit_profit_cost_90d,
'stock_days_90d', stock_days_90d, 'unit_profit_cost_180d', unit_profit_cost_180d,
'stock_days_180d', stock_days_180d, 'unit_profit_base_90d', unit_profit_base_90d,
'stock_days_365d', stock_days_365d, 'unit_profit_base_180d', unit_profit_base_180d,
'stock_days_total', stock_days_total, 'market_count_90d', market_count_90d,
'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END, 'market_count_180d', market_count_180d,
'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) ELSE 0 END, 'market_count_365d', market_count_365d,
'stock_turnover_365d', CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END, 'market_count_total', market_count_total,
'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) ELSE 0 END '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,
'performance_bucket', performance_bucket,
'idle_cost_usd', idle_cost_usd,
'stock_days_90d', stock_days_90d,
'stock_days_180d', stock_days_180d,
'stock_days_365d', stock_days_365d,
'stock_days_total', stock_days_total,
'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 ) AS row_json
FROM ( FROM (
SELECT SELECT
@@ -3314,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_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(sales_qty_180d) > 0 THEN SUM(sales_usd_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS avg_price_usd_180d,
CASE 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 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 WHEN stock_variant_rank = 1 THEN cost_price_usd * stock_qty ELSE 0 END) 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 WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) / NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END),0)
ELSE 0 ELSE COALESCE(AVG(NULLIF(cost_price_usd,0)),0)
END AS cost_price_usd, END AS cost_price_usd,
CASE 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 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 WHEN stock_variant_rank = 1 THEN base_price_usd * stock_qty ELSE 0 END) 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 WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) / NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END),0)
ELSE 0 ELSE COALESCE(AVG(NULLIF(base_price_usd,0)),0)
END AS base_price_usd, END AS base_price_usd,
CASE 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 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 WHEN stock_variant_rank = 1 THEN base_price_try * stock_qty ELSE 0 END) 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 WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) / NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END),0)
ELSE 0 ELSE COALESCE(AVG(NULLIF(base_price_try,0)),0)
END AS base_price_try, END AS base_price_try,
COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d, COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d,
COALESCE(SUM(gross_profit_usd_180d),0) AS gross_profit_usd_180d, COALESCE(SUM(gross_profit_usd_180d),0) AS gross_profit_usd_180d,
@@ -3339,17 +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_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_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, 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, COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_90d,0) > 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_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_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(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(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, 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 COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN idle_cost_usd ELSE 0 END),0) AS idle_cost_usd
FROM ( FROM (
SELECT SELECT
Source.*, 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 ( ROW_NUMBER() OVER (
PARTITION BY COALESCE(%s, ''), product_code, color_code, yaka_kodu PARTITION BY COALESCE(%s, ''), product_code, color_code, yaka_kodu
ORDER BY performance_score DESC NULLS LAST ORDER BY performance_score DESC NULLS LAST
@@ -3360,7 +4713,7 @@ FROM (
GROUP BY COALESCE(%s, '') GROUP BY COALESCE(%s, '')
) g ) g
ORDER BY group_value 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) args = append(args, field)
return queryProductPerformanceJSONRows(ctx, pg, query, args...) return queryProductPerformanceJSONRows(ctx, pg, query, args...)
} }
@@ -3374,10 +4727,10 @@ func queryProductPerformanceSQLLeafRows(ctx context.Context, pg *sql.DB, mode st
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(` query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
SELECT to_jsonb(t) || jsonb_build_object( SELECT to_jsonb(t) || jsonb_build_object(
'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key, '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_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) 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_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 ) AS row_json
FROM Source t FROM Source t
%s %s
@@ -3469,17 +4822,17 @@ Source AS (
CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan, CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan,
CASE CASE
WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN '' 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,'') ELSE COALESCE(urun_ilk_grubu,'')
END AS urun_ilk_grubu, END AS urun_ilk_grubu,
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu, COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
urun_alt_grubu, urun_alt_grubu,
market_key, market_key,
COALESCE(stock_qty,0) AS stock_qty, 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(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((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_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((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_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((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_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_90d,0) AS sales_qty_90d,
COALESCE(sales_qty_180d,0) AS sales_qty_180d, COALESCE(sales_qty_180d,0) AS sales_qty_180d,
COALESCE(sales_qty_365d,0) AS sales_qty_365d, COALESCE(sales_qty_365d,0) AS sales_qty_365d,
@@ -3506,29 +4859,43 @@ Source AS (
COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d, COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d,
COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d, COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d,
COALESCE(market_count_90d,0) AS market_count_90d, 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_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_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(price_index_90d,0) AS price_index_90d,
COALESCE(margin_index_90d,0) AS margin_index_90d, COALESCE(margin_index_90d,0) AS margin_index_90d,
COALESCE(performance_score,0) AS performance_score, COALESCE(performance_score,0) AS performance_score,
COALESCE(performance_bucket,'') AS performance_bucket, COALESCE(performance_bucket,'') AS performance_bucket,
COALESCE(recommendation,'') AS recommendation,
COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd
FROM mk_product_performance_kpi_daily FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) WHERE 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) { func productPerformanceSQLGroupExpr(field string) (string, bool) {
switch field { switch field {
case "urun_ilk_grubu": 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": case "askili_yan":
return "CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END", true 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 return field, true
case "color_yaka":
return "concat_ws('/', NULLIF(btrim(COALESCE(color_code,'')), ''), NULLIF(btrim(COALESCE(yaka_kodu,'')), ''))", true
case "market_key": case "market_key":
return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true return "btrim(regexp_replace(COALESCE(market_key,''), '^.*[|]', ''))", true
default: default:
return "", false return "", false
} }
@@ -3554,13 +4921,276 @@ func productPerformanceSQLFilterWhere(filters []productPerformanceSQLGroupFilter
if !ok { if !ok {
return "", nil, fmt.Errorf("unsupported product performance filter field: %s", filter.Field) return "", nil, fmt.Errorf("unsupported product performance filter field: %s", filter.Field)
} }
args = append(args, normalizeProductPerformanceGroupValue(filter.Value)) values := filter.Values
parts = append(parts, fmt.Sprintf("COALESCE(%s, '') = $%d", expr, len(args))) 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 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) { 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 { switch mode {
case "products": case "products":
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true}) rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
@@ -3586,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 { func productPerformanceSalesBreakdownMode(mode string) string {
switch strings.TrimSpace(mode) { switch strings.TrimSpace(mode) {
case "sales_color_yaka_market_customer": case "sales_color_yaka_market_customer":
@@ -3620,9 +5501,339 @@ func productPerformanceIdleSourceRows(rows []map[string]any) []map[string]any {
return out 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) { if level >= len(levels) {
*out = append(*out, sourceRows...)
return return
} }
@@ -3644,20 +5855,21 @@ func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map
for _, value := range values { for _, value := range values {
groupRows := grouped[value] groupRows := grouped[value]
if shouldSkipProductPerformanceGroupValue(field, 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 continue
} }
keyPart := field + ":" + value keyPart := field + ":" + value
key := strings.Join(append(parentKeys, keyPart), "|") key := strings.Join(append(parentKeys, keyPart), "|")
*out = append(*out, makeProductPerformanceGroupedRow(key, visualLevel, field, value, groupRows)) *out = append(*out, makeProductPerformanceGroupedRow(key, visualLevel, field, value, groupRows))
if expandedKeys[key] { if expandedKeys[key] || level <= expandThroughLevel {
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel+1, append(parentKeys, keyPart), expandedKeys) 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 { func makeProductPerformanceGroupedRow(key string, level int, field, value string, rows []map[string]any) map[string]any {
row := aggregateProductPerformanceRows(rows, field) row := aggregateProductPerformanceRows(rows, field)
clearProductPerformanceGroupDimensions(row, field, value)
row["__group"] = true row["__group"] = true
row["row_key"] = "group|" + key row["row_key"] = "group|" + key
row["key"] = key row["key"] = key
@@ -3666,8 +5878,7 @@ func makeProductPerformanceGroupedRow(key string, level int, field, value string
row["group_value"] = value row["group_value"] = value
row["label"] = value row["label"] = value
row["count"] = len(rows) row["count"] = len(rows)
row[field] = value row["recommendation"] = productPerformanceGroupRecommendation(row, field, len(rows))
row["recommendation"] = fmt.Sprintf("%d satır", len(rows))
image := firstProductPerformanceImageSource(rows) image := firstProductPerformanceImageSource(rows)
row["image_product_code"] = stringFromMap(image, "product_code") row["image_product_code"] = stringFromMap(image, "product_code")
@@ -3676,41 +5887,236 @@ func makeProductPerformanceGroupedRow(key string, level int, field, value string
return row 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 { func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any {
out := map[string]any{} out := map[string]any{}
marketSeen := map[string]map[string]bool{}
customerSeen := map[string]map[string]bool{}
for _, row := range rows { for _, row := range rows {
addProductPerformanceDistinctSpread(row, marketSeen, customerSeen)
for key, value := range row { for key, value := range row {
if key == "row_key" || key == "key" { if key == "row_key" || key == "key" || isProductPerformanceInternalGroupField(key) {
continue continue
} }
if key == "stock_qty" { if isProductPerformanceMarginField(key) {
out[key] = distinctProductPerformanceVariantStockQty(rows) continue
}
if isProductPerformanceDistinctVariantStockMetric(key) {
out[key] = distinctProductPerformanceVariantNumber(rows, key)
continue continue
} }
if key == "idle_cost_usd" { if key == "idle_cost_usd" {
out[key] = distinctProductPerformanceVariantStockCost(rows) out[key] = distinctProductPerformanceVariantStockCost(rows)
continue continue
} }
if shouldAverageProductPerformanceField(key) {
continue
}
if shouldSumProductPerformanceField(key) { if shouldSumProductPerformanceField(key) {
out[key] = floatFromAny(out[key]) + floatFromAny(value) out[key] = floatFromAny(out[key]) + floatFromAny(value)
} else if _, ok := out[key]; !ok && !shouldAverageProductPerformanceField(key) { } else if _, ok := out[key]; !ok {
out[key] = value out[key] = value
} }
} }
} }
for _, row := range rows { for _, row := range rows {
for key := range row { for key := range row {
if isProductPerformanceInternalGroupField(key) {
continue
}
if isProductPerformanceMarginField(key) {
continue
}
if shouldAverageProductPerformanceField(key) { if shouldAverageProductPerformanceField(key) {
out[key] = weightedAverageProductPerformanceRows(rows, key, productPerformanceMetricWeightField(key)) out[key] = weightedAverageProductPerformanceRows(rows, key, productPerformanceMetricWeightField(key))
} }
} }
} }
applyProductPerformanceDistinctSpread(out, marketSeen, customerSeen)
deriveProductPerformanceGroupMetrics(out, groupField) deriveProductPerformanceGroupMetrics(out, groupField)
out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket") out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket")
return out 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{} seen := map[string]bool{}
total := 0.0 total := 0.0
hasKey := false hasKey := false
@@ -3724,13 +6130,13 @@ func distinctProductPerformanceVariantStockQty(rows []map[string]any) float64 {
continue continue
} }
seen[key] = true seen[key] = true
total += floatFromMap(row, "stock_qty") total += floatFromMap(row, field)
} }
if hasKey { if hasKey {
return total return total
} }
for _, row := range rows { for _, row := range rows {
total += floatFromMap(row, "stock_qty") total += floatFromMap(row, field)
} }
return total return total
} }
@@ -3771,26 +6177,94 @@ func productPerformanceMapVariantKey(row map[string]any) string {
} }
func deriveProductPerformanceGroupMetrics(out map[string]any, groupField 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) sales := floatFromMap(out, "sales_usd_"+suffix)
qty := floatFromMap(out, "sales_qty_"+suffix) qty := floatFromMap(out, "sales_qty_"+suffix)
stockQty := floatFromMap(out, "stock_qty") stockQty := floatFromMap(out, "stock_qty")
if _, exists := out["stock_turnover_"+suffix]; !exists && stockQty > 0 { turnoverBase := floatFromMap(out, "avg_stock_"+suffix)
out["stock_turnover_"+suffix] = qty / stockQty 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 { if qty > 0 {
out["avg_price_usd_"+suffix] = sales / qty 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 sales > 0 {
if _, ok := out["gross_profit_base_usd_"+suffix]; ok { out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_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
if _, ok := out["gross_profit_cost_usd_"+suffix]; ok { } else {
out["gross_margin_cost_"+suffix] = floatFromMap(out, "gross_profit_cost_usd_"+suffix) / sales out["gross_margin_base_"+suffix] = 0
} out["gross_margin_cost_"+suffix] = 0
if _, ok := out["gross_profit_usd_"+suffix]; ok { out["gross_margin_"+suffix] = 0
out["gross_margin_"+suffix] = floatFromMap(out, "gross_profit_usd_"+suffix) / sales
}
} }
} }
orderUSD := floatFromMap(out, "order_usd") orderUSD := floatFromMap(out, "order_usd")
@@ -3805,11 +6279,131 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string)
if _, ok := out["net_stock_after_order"]; ok || orderQty > 0 { if _, ok := out["net_stock_after_order"]; ok || orderQty > 0 {
out["net_stock_after_order"] = floatFromMap(out, "stock_qty") - orderQty out["net_stock_after_order"] = floatFromMap(out, "stock_qty") - orderQty
} }
out["customer_score_90d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "90d")) for _, suffix := range productPerformancePeriodSuffixes() {
out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d")) if score, ok := preservedCustomerScores[suffix]; ok {
out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d")) out["customer_score_"+suffix] = score
out["customer_score_total"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "total")) } else {
out["performance_score"] = productPerformanceGroupScore(out, groupField) 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 { func productPerformanceGroupScore(row map[string]any, groupField string) float64 {
@@ -3832,22 +6426,40 @@ func isProductPerformanceCustomerGroup(groupField string) bool {
} }
func productPerformanceSalesGroupScore(row map[string]any) float64 { func productPerformanceSalesGroupScore(row map[string]any) float64 {
salesIndex := floatFromMap(row, "sales_index_90d") return productPerformanceSalesPeriodScore(row, "90d")
margin := floatFromMap(row, "gross_margin_cost_90d") }
invoiceCount := floatFromMap(row, "invoice_count_90d")
salesQty := floatFromMap(row, "sales_qty_90d") func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float64 {
stockTurnover := floatFromMap(row, "stock_turnover_90d") 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 stockTurnover == 0 {
if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 { avgStock := floatFromMap(row, "avg_stock_"+suffix)
stockTurnover = salesQty / stockQty 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) + return productPerformanceProductScore(
minFloat(30, maxFloat(0, margin)*60) + suffix,
minFloat(15, invoiceCount*1.5) + salesUSD,
minFloat(10, salesQty/10) + salesIndex,
minFloat(10, maxFloat(0, stockTurnover)*5) margin,
return score stockTurnover,
productPerformancePeriodCount(row, "market_count", suffix),
productPerformancePeriodCount(row, "customer_count", suffix),
productPerformancePeriodDays(row, suffix),
)
} }
func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 { func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 {
@@ -3875,24 +6487,208 @@ func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 {
func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 { func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 {
salesUSD := floatFromMap(row, "sales_usd") salesUSD := floatFromMap(row, "sales_usd")
margin := floatFromMap(row, "gross_margin_cost") margin := floatFromMap(row, "gross_margin_cost")
invoiceCount := floatFromMap(row, "invoice_count") if margin == 0 {
margin = floatFromMap(row, "gross_margin")
}
salesQty := floatFromMap(row, "sales_qty") salesQty := floatFromMap(row, "sales_qty")
productCount := floatFromMap(row, "product_count") productCount := floatFromMap(row, "product_count")
return minFloat(35, salesUSD/1000) + if groupCount := floatFromMap(row, "product_group_count"); groupCount > 0 {
minFloat(25, maxFloat(0, margin)*55) + productCount = groupCount
minFloat(20, invoiceCount*2) + }
minFloat(10, salesQty/10) + suffix := strings.TrimSpace(stringFromMap(row, "suffix"))
minFloat(10, productCount) 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 { func rowPeriodMetricMap(row map[string]any, suffix string) map[string]any {
return map[string]any{ return map[string]any{
"sales_usd": floatFromMap(row, "sales_usd_"+suffix), "suffix": suffix,
"gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix), "sales_usd": floatFromMap(row, "sales_usd_"+suffix),
"gross_margin": floatFromMap(row, "gross_margin_"+suffix), "gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix),
"invoice_count": floatFromMap(row, "invoice_count_"+suffix), "gross_margin": floatFromMap(row, "gross_margin_"+suffix),
"sales_qty": floatFromMap(row, "sales_qty_"+suffix), "sales_qty": floatFromMap(row, "sales_qty_"+suffix),
"product_count": floatFromMap(row, "product_count"), "product_group_count": productPerformancePeriodCount(row, "product_group_count", suffix),
"product_count": floatFromMap(row, "product_count"),
} }
} }
@@ -3929,13 +6725,16 @@ func maxFloat(a, b float64) float64 {
func sanitizeProductPerformanceGroupLevels(levels []string) []string { func sanitizeProductPerformanceGroupLevels(levels []string) []string {
allowed := map[string]bool{ allowed := map[string]bool{
"kategori": true, "askili_yan": true, "urun_ilk_grubu": true, "urun_ana_grubu": true, "urun_alt_grubu": true, "kategori": true, "askili_yan": true, "urun_ilk_grubu": true, "urun_ana_grubu": true, "urun_alt_grubu": true,
"product_code": true, "item_description": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true, "product_code": true, "item_description": true, "color_code": true, "yaka_kodu": true, "color_yaka": true, "market_key": true, "customer_code": true,
"customer_name": true, "country": true, "customer_segment": true, "customer_name": true, "country": true, "customer_segment": true,
} }
out := make([]string, 0, len(levels)) out := make([]string, 0, len(levels))
seen := map[string]bool{} seen := map[string]bool{}
for _, level := range levels { for _, level := range levels {
level = strings.TrimSpace(level) level = strings.TrimSpace(level)
if level == "color_code" || level == "yaka_kodu" {
level = "color_yaka"
}
if allowed[level] && !seen[level] { if allowed[level] && !seen[level] {
out = append(out, level) out = append(out, level)
seen[level] = true seen[level] = true
@@ -3947,21 +6746,23 @@ func sanitizeProductPerformanceGroupLevels(levels []string) []string {
func defaultProductPerformanceGroupLevels(mode string) []string { func defaultProductPerformanceGroupLevels(mode string) []string {
switch mode { switch mode {
case "sales_color_yaka_market_customer": case "sales_color_yaka_market_customer":
return []string{"color_code", "yaka_kodu", "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_alt_grubu", "product_code", "color_yaka", "market_key"}
case "idle": 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": 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": 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": 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": 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": 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: 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"}
} }
} }
@@ -3971,13 +6772,31 @@ func mapGroupValue(row map[string]any, field string) string {
return cleanProductPerformanceFirstGroup(stringFromMap(row, "urun_ilk_grubu")) return cleanProductPerformanceFirstGroup(stringFromMap(row, "urun_ilk_grubu"))
case "market_key": case "market_key":
return displayProductPerformanceMarketName(stringFromMap(row, "market_key")) return displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
case "color_yaka":
parts := make([]string, 0, 2)
if color := strings.TrimSpace(stringFromMap(row, "color_code")); color != "" {
parts = append(parts, color)
}
if yaka := strings.TrimSpace(stringFromMap(row, "yaka_kodu")); yaka != "" {
parts = append(parts, yaka)
}
return strings.Join(parts, "/")
default: default:
return stringFromMap(row, field) return stringFromMap(row, field)
} }
} }
func shouldSumProductPerformanceField(field string) bool { 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, "_usd") ||
strings.HasSuffix(field, "_count") || strings.HasSuffix(field, "_count") ||
strings.HasSuffix(field, "_value_usd") || strings.HasSuffix(field, "_value_usd") ||
@@ -3994,6 +6813,9 @@ func shouldSumProductPerformanceField(field string) bool {
} }
func shouldAverageProductPerformanceField(field string) bool { func shouldAverageProductPerformanceField(field string) bool {
if strings.HasPrefix(field, "avg_daily_sales_") {
return false
}
return strings.HasPrefix(field, "avg_") || return strings.HasPrefix(field, "avg_") ||
strings.HasPrefix(field, "unit_") || strings.HasPrefix(field, "unit_") ||
strings.HasPrefix(field, "base_price") || strings.HasPrefix(field, "base_price") ||
@@ -4002,11 +6824,18 @@ func shouldAverageProductPerformanceField(field string) bool {
strings.HasPrefix(field, "expected_margin") || strings.HasPrefix(field, "expected_margin") ||
strings.HasPrefix(field, "sales_index") || strings.HasPrefix(field, "sales_index") ||
strings.HasPrefix(field, "performance_score") || strings.HasPrefix(field, "performance_score") ||
strings.HasPrefix(field, "customer_score") ||
strings.HasPrefix(field, "stock_days") || strings.HasPrefix(field, "stock_days") ||
strings.HasPrefix(field, "stock_turnover") strings.HasPrefix(field, "stock_turnover")
} }
func productPerformanceMetricWeightField(field string) string { 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") { if strings.Contains(field, "_180d") {
return "sales_qty_180d" return "sales_qty_180d"
} }
@@ -4025,11 +6854,75 @@ func productPerformanceMetricWeightField(field string) string {
return "sales_qty_90d" 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 { func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qtyField string) float64 {
if productPerformanceUsesScoreWeight(valueField) {
return weightedAverageProductPerformanceScoreRows(rows, valueField, productPerformanceScoreSuffix(valueField))
}
var weighted, qty float64 var weighted, qty float64
for _, row := range rows { for _, row := range rows {
value := floatFromMap(row, valueField) value := floatFromMap(row, valueField)
weight := floatFromMap(row, qtyField) weight := floatFromMap(row, qtyField)
if qtyField == "__product_cost_weight" {
weight = productPerformanceCostWeight(row)
}
if weight > 0 { if weight > 0 {
weighted += value * weight weighted += value * weight
qty += weight qty += weight
@@ -4038,9 +6931,74 @@ func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qt
if qty > 0 { if qty > 0 {
return weighted / qty return weighted / qty
} }
if qtyField == "__product_cost_weight" {
return averageProductPerformanceDistinctVariantField(rows, valueField)
}
return 0 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 { func dominantProductPerformanceValue(rows []map[string]any, field string) string {
counts := map[string]int{} counts := map[string]int{}
for _, row := range rows { for _, row := range rows {
@@ -4123,9 +7081,7 @@ func cleanProductPerformanceFirstGroup(value string) string {
if value == "-" { if value == "-" {
return "" return ""
} }
normalized := strings.ToUpper(value) switch normalizeProductPerformanceTurkishText(value) {
normalized = strings.NewReplacer("İ", "I", "Ş", "S", "Ğ", "G", "Ü", "U", "Ö", "O", "Ç", "C").Replace(normalized)
switch normalized {
case "YETISKIN", "YETISKIN/GARSON", "GARSON": case "YETISKIN", "YETISKIN/GARSON", "GARSON":
return "" return ""
default: default:
@@ -4133,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 { func cleanProductPerformanceOptionalAttr(value string) string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "-" { if value == "-" {
@@ -4145,6 +7119,10 @@ func floatFromMap(row map[string]any, field string) float64 {
return floatFromAny(row[field]) return floatFromAny(row[field])
} }
func intFromMap(row map[string]any, field string) int {
return int(floatFromAny(row[field]))
}
func floatFromAny(value any) float64 { func floatFromAny(value any) float64 {
switch v := value.(type) { switch v := value.(type) {
case float64: case float64:
@@ -4163,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) { func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
if limit <= 0 || limit > 500 { if limit <= 0 || limit > 500 {
limit = 100 limit = 100
@@ -4183,7 +7175,7 @@ FROM mk_product_performance_sales_daily
WHERE product_code=$1 WHERE product_code=$1
AND color_code=$2 AND color_code=$2
AND yaka_kodu=$3 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 ORDER BY sales_usd DESC, sales_date DESC
LIMIT $4 LIMIT $4
`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit) `, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit)
@@ -4343,6 +7335,16 @@ func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) {
return strings.Join(parts, ""), args 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 { func productPerformanceOrderBy(sortBy string, desc bool) string {
allowed := map[string]string{ allowed := map[string]string{
"product_code": "product_code", "product_code": "product_code",
@@ -4424,21 +7426,30 @@ func productPerformanceVariantKey(productCode, colorCode, yakaKodu string) strin
} }
func normalizeProductPerformanceCostPair(costPriceUSD, basePriceUSD float64) (float64, float64) { 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 basePriceUSD, costPriceUSD
} }
return costPriceUSD, basePriceUSD 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 { func productPerformanceAllowedFirstGroupSQL(expr string) string {
return "upper(btrim(COALESCE(" + expr + ",''))) NOT IN " + productPerformanceExcludedFirstGroupsSQL return productPerformanceNormalizedTextSQL(expr) + " NOT IN " + productPerformanceExcludedFirstGroupsSQL
} }
func isExcludedProductPerformanceFirstGroup(value string) bool { func isExcludedProductPerformanceFirstGroup(value string) bool {
switch strings.ToUpper(strings.TrimSpace(value)) { switch normalizeProductPerformanceTurkishText(value) {
case "MALZEMELI FASON", "MALZEMESIZ FASON", "DIGER": case "MALZEMELI FASON", "MALZEMESIZ FASON", "MAZLEMELI FASON", "MAZEMESIZ FASON", "DIGER":
return true return true
default: default:
return false return false
@@ -4507,7 +7518,7 @@ SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
FROM mk_product_performance_kpi_daily FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT kpi_date FROM Latest) WHERE kpi_date = (SELECT kpi_date FROM Latest)
AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1) 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 ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
`, pq.Array(variantKeys)) `, pq.Array(variantKeys))
if err != nil { if err != nil {
@@ -4563,68 +7574,68 @@ GROUP BY product_code, color_code, yaka_kodu
func productPerformanceOrderRecommendation(row models.ProductPerformanceOrderAnalysisRow) (string, string) { func productPerformanceOrderRecommendation(row models.ProductPerformanceOrderAnalysisRow) (string, string) {
switch { switch {
case row.OrderQty > 0 && row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0: 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: 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: 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: 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: 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: 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) { func productPerformanceOrderGroupRecommendation(row models.ProductPerformanceOrderGroupRow) (string, string) {
switch { switch {
case row.ExpectedProfitCostUSD < 0: 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: 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: 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: 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: case row.OverdueQty > 0:
return "TAKIP", "Geciken açık sipariş var. Operasyon takibi gerekli." return "TAKIP", "Geciken acik siparis var. Operasyon takibi gerekli."
default: 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) { func productPerformanceOrderProductCustomerRecommendation(row models.ProductPerformanceOrderProductCustomerRow) (string, string) {
switch { switch {
case row.ExpectedProfitCostUSD < 0: 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: 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: 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: 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: 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: 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) { func productPerformanceOrderMarketDetailRecommendation(row models.ProductPerformanceOrderMarketDetailRow) (string, string) {
switch { switch {
case row.ExpectedProfitCostUSD < 0: 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: 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: 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: case row.IsOverdue:
return "TAKIP", "Termin geçmiş; teslimat aksiyonu gerekli." return "TAKIP", "Termin gecmis; teslimat aksiyonu gerekli."
case row.ExpectedMarginCost >= 0.25: case row.ExpectedMarginCost >= 0.25:
return "YILDIZ_URUN", "Sipariş karlı; teslimat ve stok korunmalı." return "YILDIZ_URUN", "Siparis karli; teslimat ve stok korunmali."
default: default:
return "TAKIP", "Sipariş fiyatı, stok ve termin izlenmeli." return "TAKIP", "Siparis fiyati, stok ve termin izlenmeli."
} }
} }
+158 -48
View File
@@ -76,16 +76,19 @@ func productPerformanceSalesSQL() string {
), SPACE(0))) ), SPACE(0)))
END, END,
Qty1 = ISNULL(I.Qty1, 0), Qty1 = ISNULL(I.Qty1, 0),
TLAmount = ISNULL(I.Doc_Amount, 0) * ISNULL(I.Loc_ExchangeRate, 0), TLAmount = CASE
USDAmount = (ISNULL(I.Doc_Amount, 0) * ISNULL(I.Loc_ExchangeRate, 0)) / ISNULL(( WHEN Amount.DocCurrencyCode = 'TRY' THEN Amount.DocNetAmount
SELECT TOP 1 Rate WHEN CurRate.Rate > 0 THEN Amount.DocNetAmount * CurRate.Rate
FROM AllExchangeRates WITH(NOLOCK) WHEN ISNULL(I.Loc_ExchangeRate, 0) > 0 THEN Amount.DocNetAmount * I.Loc_ExchangeRate
WHERE CurrencyCode = 'USD' ELSE 0
AND RelationCurrencyCode = 'TRY' END,
AND ExchangeTypeCode = 6 USDAmount = CASE
AND Rate > 1 WHEN Amount.DocCurrencyCode = 'USD' THEN Amount.DocNetAmount
ORDER BY ABS(DATEDIFF(DAY, AllExchangeRates.Date, I.InvoiceDate)) ASC WHEN Amount.DocCurrencyCode = 'TRY' AND UsdRate.Rate > 0 THEN Amount.DocNetAmount / UsdRate.Rate
), 1), 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 ( RN = ROW_NUMBER() OVER (
PARTITION BY I.InvoiceLineID PARTITION BY I.InvoiceLineID
ORDER BY I.InvoiceDate DESC, I.InvoiceNumber ORDER BY I.InvoiceDate DESC, I.InvoiceNumber
@@ -96,6 +99,39 @@ func productPerformanceSalesSQL() string {
AND CAF.CurrAccCode = I.CurrAccCode AND CAF.CurrAccCode = I.CurrAccCode
LEFT OUTER JOIN ProductAttributesFilter PAF WITH(NOLOCK) LEFT OUTER JOIN ProductAttributesFilter PAF WITH(NOLOCK)
ON PAF.ItemCode = I.ItemCode 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 WHERE I.InvoiceDate >= @p1
AND I.InvoiceDate < DATEADD(DAY, 1, @p2) AND I.InvoiceDate < DATEADD(DAY, 1, @p2)
AND I.ItemTypeCode = 1 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 '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 >= $1::date - INTERVAL '359 days') AS sales_usd_365d,
SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01') AS sales_usd_total, 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(sales_date) AS last_sale_date,
MAX(last_ref_number) AS last_ref_number MAX(last_ref_number) AS last_ref_number
FROM mk_product_performance_sales_daily FROM mk_product_performance_sales_daily
WHERE sales_date >= DATE '2022-01-01' WHERE sales_date >= DATE '2022-01-01'
AND sales_date <= $1::date 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 GROUP BY product_code, color_code, yaka_kodu, market_key
), ),
Scope AS ( 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, 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_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, 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 FROM SalesAgg
UNION ALL UNION ALL
SELECT SELECT
@@ -492,12 +537,19 @@ Scope AS (
0 AS sales_usd_365d, 0 AS sales_usd_365d,
0 AS sales_usd_total, 0 AS sales_usd_total,
0 AS customer_count_90d, 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, NULL::date AS last_sale_date,
'' AS last_ref_number '' AS last_ref_number
FROM LatestStock ls FROM LatestStock ls
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=ls.product_code LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=ls.product_code
WHERE COALESCE(ls.stock_qty, 0) <> 0 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 ( AND NOT EXISTS (
SELECT 1 SELECT 1
FROM SalesAgg sa FROM SalesAgg sa
@@ -509,12 +561,15 @@ Scope AS (
PriceDim AS ( PriceDim AS (
SELECT SELECT
product_code, product_code,
COALESCE(urun_ilk_grubu, '') AS urun_ilk_grubu,
COALESCE(urun_ana_grubu, '') AS urun_ana_grubu,
COALESCE(urun_alt_grubu, '') AS urun_alt_grubu,
CASE 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 ELSE cost_price_usd
END AS cost_price_usd, END AS cost_price_usd,
CASE 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 ELSE base_price_usd
END AS base_price_usd, END AS base_price_usd,
base_price_try base_price_try
@@ -531,10 +586,10 @@ Base AS (
COALESCE(s.sales_qty_180d, 0) / 180.0 AS avg_daily_sales_180d, 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_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(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(s90.stock_qty, ls.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(s180.stock_qty, ls.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(s365.stock_qty, ls.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(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_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, CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0) END AS avg_price_usd_180d,
COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_90d, COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_90d,
@@ -551,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 CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0)) - COALESCE(pd.base_price_usd, 0) END AS unit_profit_base_180d
FROM Scope s FROM Scope s
LEFT JOIN LatestStock ls ON ls.product_code=s.product_code AND ls.color_code=s.color_code AND ls.yaka_kodu=s.yaka_kodu LEFT JOIN LatestStock ls ON ls.product_code=s.product_code AND ls.color_code=s.color_code AND ls.yaka_kodu=s.yaka_kodu
LEFT JOIN 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 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 ( Spread AS (
SELECT SELECT
product_code, product_code,
color_code, color_code,
yaka_kodu, 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 FROM SalesAgg
WHERE COALESCE(sales_qty_90d, 0) > 0
GROUP BY product_code, color_code, yaka_kodu GROUP BY product_code, color_code, yaka_kodu
), ),
Market AS ( Market AS (
SELECT SELECT
market_key, market_key,
AVG(NULLIF(sales_qty_90d, 0)) AS market_avg_sales_qty_90d, 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(avg_price_usd_90d, 0)) AS market_avg_price_usd_90d,
AVG(NULLIF(gross_margin_90d, 0)) AS market_avg_margin_90d AVG(NULLIF(gross_margin_90d, 0)) AS market_avg_margin_90d
FROM Base FROM Base
@@ -581,15 +677,21 @@ Scored AS (
SELECT SELECT
b.*, b.*,
COALESCE(sp.market_count_90d, 0) AS market_count_90d, 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_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_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_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_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_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) END AS stock_turnover_180d, 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_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_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_price_usd_90d, 0) = 0 THEN 0 ELSE b.avg_price_usd_90d / NULLIF(m.market_avg_price_usd_90d, 0) END AS price_index_90d,
CASE WHEN COALESCE(m.market_avg_margin_90d, 0) = 0 THEN 0 ELSE b.gross_margin_90d / NULLIF(m.market_avg_margin_90d, 0) END AS margin_index_90d CASE WHEN COALESCE(m.market_avg_margin_90d, 0) = 0 THEN 0 ELSE b.gross_margin_90d / NULLIF(m.market_avg_margin_90d, 0) END AS margin_index_90d
FROM Base b FROM Base b
@@ -601,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, 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_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, 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, 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, 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, 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, unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d,
sales_index_90d, price_index_90d, margin_index_90d, performance_score, 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 performance_bucket, recommendation, last_sale_date, last_ref_number, updated_at
) )
SELECT SELECT
@@ -614,20 +719,25 @@ SELECT
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty,
COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_180d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0), COALESCE(sales_qty_total, 0), COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_180d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0), COALESCE(sales_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(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(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(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(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(unit_profit_cost_90d, 0), COALESCE(unit_profit_cost_180d, 0), COALESCE(unit_profit_base_90d, 0), COALESCE(unit_profit_base_180d, 0),
COALESCE(sales_index_90d, 0), COALESCE(price_index_90d, 0), COALESCE(margin_index_90d, 0), COALESCE(market_count_90d, 0), COALESCE(market_count_180d, 0), COALESCE(market_count_365d, 0), COALESCE(market_count_total, 0),
ROUND( COALESCE(customer_count_90d, 0), COALESCE(customer_count_180d, 0), COALESCE(customer_count_365d, 0), COALESCE(customer_count_total, 0),
LEAST(35, COALESCE(sales_index_90d, 0) * 18) 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),
+ LEAST(25, GREATEST(COALESCE(gross_margin_90d, 0), 0) * 50) CASE WHEN COALESCE(sales_usd_90d, 0) <= 0 THEN 1 ELSE
+ LEAST(20, COALESCE(customer_count_90d, 0) * 2) ROUND(
+ LEAST(10, COALESCE(market_count_90d, 0) * 3) LEAST(30, GREATEST(COALESCE(gross_margin_90d, 0), 0) / 0.45 * 30)
+ 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 + LEAST(20, COALESCE(stock_turnover_90d, 0) / 4.0 * 20)
, 4) AS performance_score, + 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 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_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(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' WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'FIYAT_BASKISI'
@@ -635,12 +745,12 @@ SELECT
ELSE 'TAKIP' ELSE 'TAKIP'
END AS performance_bucket, END AS performance_bucket,
CASE 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(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: üretim/satın alma planına alınmalı.' 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 yüksek, satış piyasa altı: kampanya veya fiyat kontrolü gerekli.' 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 üstünde ve satış zayıf: fiyat revizyonu değerlendirilmeli.' 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 'Satış güçlü, fiyat piyasa altı: taban fiyat artışı değerlendirilebilir.' 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 'Düzenli takip.' ELSE 'Duzenli takip.'
END AS recommendation, END AS recommendation,
last_sale_date, last_sale_date,
COALESCE(last_ref_number, ''), COALESCE(last_ref_number, ''),
+92 -5
View File
@@ -793,6 +793,82 @@ ORDER BY SonIsEmri.dteIslemTarihi DESC, LTRIM(RTRIM(R.sMModelKodu)) ASC
return uretimDB.QueryContext(ctx, sqlText, fromDate, search) 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( func GetProductionHasCostProducts(
ctx context.Context, ctx context.Context,
uretimDB *sql.DB, uretimDB *sql.DB,
@@ -816,12 +892,22 @@ WITH SonOnMaliyet AS (
FROM dbo.spUrtOnMLMas M FROM dbo.spUrtOnMLMas M
WHERE LTRIM(RTRIM(M.UrunKodu)) <> '' WHERE LTRIM(RTRIM(M.UrunKodu)) <> ''
), ),
SonSiparis AS ( SonSiparisSirali AS (
SELECT SELECT
LTRIM(RTRIM(sMModelKodu)) AS UrunKodu, LTRIM(RTRIM(SD.sMModelKodu)) AS UrunKodu,
MAX(dteIslemTarihi) AS SonSiparisTarihi SD.dteIslemTarihi AS SonSiparisTarihi,
FROM dbo.spUrtSiparisDet SM.nUrtSiparisNo,
GROUP BY LTRIM(RTRIM(sMModelKodu)) 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 SELECT
CASE CASE
@@ -831,6 +917,7 @@ SELECT
THEN N'FASON ICIN URETILEN' THEN N'FASON ICIN URETILEN'
ELSE N'Tanimsiz' ELSE N'Tanimsiz'
END AS UretimSekli, END AS UretimSekli,
RTRIM(CONVERT(VARCHAR(32), ISNULL(SS.nUrtSiparisNo, 0))) AS nUrtSiparisNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(OM.nOnMLNo, 0))) AS nOnMLNo, RTRIM(CONVERT(VARCHAR(32), ISNULL(OM.nOnMLNo, 0))) AS nOnMLNo,
LTRIM(RTRIM(ISNULL(OM.UrunKodu, ''))) AS UrunKodu, LTRIM(RTRIM(ISNULL(OM.UrunKodu, ''))) AS UrunKodu,
ISNULL(OM.UrunAdi, '') AS UrunAdi, ISNULL(OM.UrunAdi, '') AS UrunAdi,
+136 -25
View File
@@ -17,14 +17,16 @@ import (
) )
type ProductImageItem struct { type ProductImageItem struct {
ID int64 `json:"id"` ID int64 `json:"id"`
FileName string `json:"file_name"` FileName string `json:"file_name"`
FileSize int64 `json:"file_size"` FileSize int64 `json:"file_size"`
Storage string `json:"storage_path"` Storage string `json:"storage_path"`
ContentURL string `json:"content_url"` ContentURL string `json:"content_url"`
UUID string `json:"uuid,omitempty"` UUID string `json:"uuid,omitempty"`
ThumbURL string `json:"thumb_url,omitempty"` ThumbURL string `json:"thumb_url,omitempty"`
FullURL string `json:"full_url,omitempty"` FullURL string `json:"full_url,omitempty"`
StoredInDB bool `json:"stored_in_db,omitempty"`
HasDBContent bool `json:"-"`
} }
type ProductImageBatchItem struct { type ProductImageBatchItem struct {
@@ -136,6 +138,74 @@ func extractImageUUID(storagePath, fileName string) string {
return "" 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 // POST /api/product-images/batch
func PostProductImagesBatchHandler(pg *sql.DB) http.HandlerFunc { func PostProductImagesBatchHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -208,6 +278,8 @@ ranked AS (
COALESCE(b.file_name,'') AS file_name, COALESCE(b.file_name,'') AS file_name,
COALESCE(b.file_size,0) AS file_size, COALESCE(b.file_size,0) AS file_size,
COALESCE(b.storage_path,'') AS storage_path, 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 ( ROW_NUMBER() OVER (
PARTITION BY mi.key PARTITION BY mi.key
ORDER BY ORDER BY
@@ -225,9 +297,9 @@ ranked AS (
AND b.typ='img' AND b.typ='img'
AND b.src_id=mi.mmitem_id 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 FROM ranked
WHERE rn <= 1 WHERE rn <= 5
ORDER BY key, rn ORDER BY key, rn
`, string(payload)) `, string(payload))
if err != nil { if err != nil {
@@ -241,14 +313,12 @@ ORDER BY key, rn
for rows.Next() { for rows.Next() {
var key string var key string
var it ProductImageItem 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 continue
} }
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID) enrichProductImageItem(&it)
if u := extractImageUUID(it.Storage, it.FileName); u != "" { if !productImageItemHasURL(&it) {
it.UUID = u continue
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
it.FullURL = "/uploads/image/" + u + ".jpg"
} }
grouped[key] = append(grouped[key], it) grouped[key] = append(grouped[key], it)
} }
@@ -339,7 +409,9 @@ SELECT
id, id,
COALESCE(file_name,'') AS file_name, COALESCE(file_name,'') AS file_name,
COALESCE(file_size,0) AS file_size, 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 FROM dfblob
WHERE typ='img' WHERE typ='img'
AND src_table='mmitem' AND src_table='mmitem'
@@ -370,16 +442,13 @@ ORDER BY
items := make([]ProductImageItem, 0, 16) items := make([]ProductImageItem, 0, 16)
for rows.Next() { for rows.Next() {
var it ProductImageItem 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 continue
} }
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID) enrichProductImageItem(&it)
if u := extractImageUUID(it.Storage, it.FileName); u != "" { if productImageItemHasURL(&it) {
it.UUID = u items = append(items, it)
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
it.FullURL = "/uploads/image/" + u + ".jpg"
} }
items = append(items, it)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err return nil, err
@@ -521,7 +590,8 @@ WHERE id = $1
return return
} }
if storedInDB && len(binData) > 0 { _ = storedInDB
if len(binData) > 0 {
w.Header().Set("Content-Type", http.DetectContentType(binData)) w.Header().Set("Content-Type", http.DetectContentType(binData))
w.Header().Set("Cache-Control", "public, max-age=3600") w.Header().Set("Cache-Control", "public, max-age=3600")
_, _ = w.Write(binData) _, _ = w.Write(binData)
@@ -529,6 +599,9 @@ WHERE id = $1
} }
resolved, _ := resolveStoragePath(storagePath) resolved, _ := resolveStoragePath(storagePath)
if resolved == "" {
resolved, _ = resolveStoragePath(fileName)
}
if resolved == "" { if resolved == "" {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -562,14 +635,42 @@ func resolveStoragePath(storagePath string) (string, []string) {
raw = filepath.ToSlash(filepath.Clean(raw)) raw = filepath.ToSlash(filepath.Clean(raw))
relUploads := filepath.FromSlash(filepath.Join("uploads", 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{ candidates := []string{
filepath.Clean(storagePath), filepath.Clean(storagePath),
filepath.FromSlash(filepath.Clean(strings.TrimPrefix(storagePath, "/"))), filepath.FromSlash(filepath.Clean(strings.TrimPrefix(storagePath, "/"))),
filepath.FromSlash(filepath.Clean(raw)), filepath.FromSlash(filepath.Clean(raw)),
relUploads, relUploads,
relImage,
relImageThumb,
filepath.Join(".", relUploads), filepath.Join(".", relUploads),
filepath.Join(".", relImage),
filepath.Join(".", relImageThumb),
filepath.Join("..", relUploads), filepath.Join("..", relUploads),
filepath.Join("..", relImage),
filepath.Join("..", relImageThumb),
filepath.Join("..", "..", relUploads), 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 != "" { 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, raw),
filepath.Join(root, relUploads), filepath.Join(root, relUploads),
filepath.Join(root, "uploads", raw), 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 { for _, p := range candidates {
+124 -28
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -16,7 +17,7 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
f := queries.ProductPerformanceFilters{ f := queries.ProductPerformanceFilters{
@@ -90,7 +91,7 @@ func GetProductPerformanceSummaryHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
summary, err := queries.GetProductPerformanceSummary(ctx, pg) 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) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
rows, err := queries.ListProductPerformanceGeneral(ctx, pg, intQuery(r, "limit", 50000)) 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) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
rows, err := queries.ListProductPerformanceOrderAnalysis(ctx, pg, intQuery(r, "limit", 50000)) 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) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
rows, err := queries.ListProductPerformanceOrderGroups( rows, err := queries.ListProductPerformanceOrderGroups(
@@ -159,7 +160,7 @@ func GetProductPerformanceOrderProductCustomersHandler(pg *sql.DB) http.HandlerF
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
rows, err := queries.ListProductPerformanceOrderProductCustomers(ctx, pg, intQuery(r, "limit", 50000)) 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) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
rows, err := queries.ListProductPerformanceOrderMarketDetails(ctx, pg, intQuery(r, "limit", 50000)) 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) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
rows, err := queries.ListProductPerformanceMarkets(ctx, pg, intQuery(r, "limit", 50000)) 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) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
rows, err := queries.ListProductPerformanceCountries(ctx, pg, intQuery(r, "limit", 50000)) 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) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
rows, err := queries.ListProductPerformanceCustomers( rows, err := queries.ListProductPerformanceCustomers(
@@ -244,7 +245,7 @@ func GetProductPerformanceSalesBreakdownHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
rows, err := queries.ListProductPerformanceSalesBreakdown( rows, err := queries.ListProductPerformanceSalesBreakdown(
@@ -265,19 +266,41 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r) 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() defer cancel()
mode := strings.TrimSpace(r.URL.Query().Get("mode")) 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")) groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels"))
limit := intQuery(r, "limit", 50000) limit := intQuery(r, "limit", 50000)
expanded := map[string]bool{} 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 { if r.Method == http.MethodPost {
var body struct { var body struct {
Mode string `json:"mode"` Mode string `json:"mode"`
GroupLevels []string `json:"group_levels"` GroupLevels []string `json:"group_levels"`
ExpandedKeys []string `json:"expanded_keys"` ExpandedKeys []string `json:"expanded_keys"`
Limit int `json:"limit"` 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 { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest) http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest)
@@ -286,6 +309,9 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
if strings.TrimSpace(body.Mode) != "" { if strings.TrimSpace(body.Mode) != "" {
mode = strings.TrimSpace(body.Mode) mode = strings.TrimSpace(body.Mode)
} }
if strings.TrimSpace(body.MainGroup) != "" {
mainGroup = strings.TrimSpace(body.MainGroup)
}
if len(body.GroupLevels) > 0 { if len(body.GroupLevels) > 0 {
groupLevels = body.GroupLevels groupLevels = body.GroupLevels
} }
@@ -293,27 +319,39 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
limit = body.Limit limit = body.Limit
} }
for _, key := range body.ExpandedKeys { for _, key := range body.ExpandedKeys {
key = strings.TrimSpace(key) addExpandedKey(key)
if key != "" { }
expanded[key] = true 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 { } else {
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") { for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
key = strings.TrimSpace(key) addExpandedKey(key)
if key != "" {
expanded[key] = true
}
} }
} }
rows, err := queries.ListProductPerformanceGrouped(ctx, pg, queries.ProductPerformanceGroupedRequest{ rows, err := queries.ListProductPerformanceGrouped(ctx, pg, queries.ProductPerformanceGroupedRequest{
Mode: mode, Mode: mode,
GroupLevels: groupLevels, GroupLevels: groupLevels,
ExpandedKeys: expanded, ExpandedKeys: expanded,
Limit: limit, ExpandThroughLevel: expandThroughLevel,
Limit: limit,
MainGroup: mainGroup,
Filters: filters,
SortBy: sortBy,
Descending: descending,
}) })
if err != nil { 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) http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
return return
} }
@@ -321,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 { func splitCSVQuery(value string) []string {
parts := strings.Split(value, ",") parts := strings.Split(value, ",")
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))
+1140
View File
@@ -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
}
+47
View File
@@ -158,6 +158,26 @@ func GetProductionNoCostProductsHandler(w http.ResponseWriter, r *http.Request)
return 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) _ = json.NewEncoder(w).Encode(list)
} }
@@ -191,6 +211,7 @@ func GetProductionHasCostProductsHandler(w http.ResponseWriter, r *http.Request)
var item models.ProductionHasCostProductRow var item models.ProductionHasCostProductRow
if err := rows.Scan( if err := rows.Scan(
&item.UretimSekli, &item.UretimSekli,
&item.UrtSiparisNo,
&item.NOnMLNo, &item.NOnMLNo,
&item.UrunKodu, &item.UrunKodu,
&item.UrunAdi, &item.UrunAdi,
@@ -219,6 +240,26 @@ func GetProductionHasCostProductsHandler(w http.ResponseWriter, r *http.Request)
return 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) _ = json.NewEncoder(w).Encode(list)
} }
@@ -1427,6 +1468,12 @@ func postProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
totalUSD := 0.0 totalUSD := 0.0
totalEUR := 0.0 totalEUR := 0.0
for _, r := range req.Detail.Upserts { 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 qty := r.LMiktar
if qty < 0 { if qty < 0 {
qty = 0 qty = 0
+15 -1
View File
@@ -1,8 +1,20 @@
// quasar.config.js // quasar.config.js
import { defineConfig } from '#q-app/wrappers' 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(() => { export default defineConfig(() => {
const apiBaseUrl = (process.env.VITE_API_BASE_URL || '/api').trim() const apiBaseUrl = (process.env.VITE_API_BASE_URL || '/api').trim()
const gitBuildInfo = readGitBuildInfo()
return { return {
@@ -36,7 +48,9 @@ export default defineConfig(() => {
build: { build: {
vueRouterMode: 'hash', vueRouterMode: 'hash',
env: { env: {
VITE_API_BASE_URL: apiBaseUrl VITE_API_BASE_URL: apiBaseUrl,
APP_VERSION: gitBuildInfo.version,
APP_COMMIT: gitBuildInfo.commit
}, },
esbuildTarget: { esbuildTarget: {
+21
View File
@@ -74,6 +74,10 @@
</q-form> </q-form>
</q-card> </q-card>
<div class="login-version" :title="`Commit: ${appCommit}`">
Baggi BSS {{ appVersion }}
</div>
<!-- 🔐 FORGOT PASSWORD --> <!-- 🔐 FORGOT PASSWORD -->
<q-dialog v-model="forgotOpen" persistent> <q-dialog v-model="forgotOpen" persistent>
<q-card style="width:420px; max-width:90vw"> <q-card style="width:420px; max-width:90vw">
@@ -129,6 +133,8 @@ import api from 'src/services/api'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const $q = useQuasar() const $q = useQuasar()
const appVersion = process.env.APP_VERSION || 'v1.0.0'
const appCommit = process.env.APP_COMMIT || 'local'
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
@@ -266,6 +272,21 @@ async function sendResetMail () {
.full-width { .full-width {
width: 100%; 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) LOGIN INPUT TEXT COLOR (GOLD)
=============================== */ =============================== */
+2 -4
View File
@@ -1289,14 +1289,12 @@ function normalizeUploadsPath (storagePath) {
function resolveProductImageUrl (item) { function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return '' if (!item || typeof item !== 'object') return ''
const imageId = Number(item.id || item.ID || 0) const contentUrl = toText(item.content_url || item.ContentURL)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content` if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl
const thumbUrl = toText(item.thumb_url || item.thumbUrl) const thumbUrl = toText(item.thumb_url || item.thumbUrl)
if (thumbUrl) return thumbUrl if (thumbUrl) return thumbUrl
const fullUrl = toText(item.full_url || item.fullUrl) const fullUrl = toText(item.full_url || item.fullUrl)
if (fullUrl) return 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) const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage)
if (uploadsPath) return uploadsPath if (uploadsPath) return uploadsPath
const fileName = toText(item.file_name || item.FileName) const fileName = toText(item.file_name || item.FileName)
+2398 -560
View File
@@ -1,4 +1,4 @@
<template> <template>
<q-page class="product-performance-page q-pa-sm"> <q-page class="product-performance-page q-pa-sm">
<q-inner-loading :showing="pageBusy"> <q-inner-loading :showing="pageBusy">
<q-spinner-gears size="52px" color="primary" /> <q-spinner-gears size="52px" color="primary" />
@@ -14,7 +14,8 @@
@wheel.stop @wheel.stop
> >
<q-spinner-gears size="56px" color="primary" /> <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> </div>
</teleport> </teleport>
<div class="performance-top-bar row items-center justify-between q-col-gutter-xs q-mb-xs"> <div class="performance-top-bar row items-center justify-between q-col-gutter-xs q-mb-xs">
@@ -34,7 +35,18 @@
:disable="pageBusy" :disable="pageBusy"
class="period-selector bg-white q-px-xs q-py-none" 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 <q-btn-dropdown
v-model="detailLevelMenuOpen"
split
dense dense
outline outline
color="primary" color="primary"
@@ -52,11 +64,12 @@
/> />
<q-separator class="q-my-sm" /> <q-separator class="q-my-sm" />
<div class="row items-center justify-end q-gutter-xs"> <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 flat color="grey-7" label="Kapat" :disable="pageBusy" @click.stop="collapseAllProductGroups" />
<q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click="expandSelectedProductGroups" /> <q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click.stop="expandSelectedProductGroups" />
</div> </div>
</div> </div>
</q-btn-dropdown> </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" /> <q-btn dense outline color="secondary" icon="refresh" label="Yenile" :loading="loading" :disable="pageBusy" @click="reload" />
</div> </div>
</div> </div>
@@ -86,18 +99,28 @@
</button> </button>
</div> </div>
<div ref="topScrollbarRef" class="performance-top-scrollbar q-mb-xs">
<div ref="topScrollbarInnerRef" class="performance-top-scrollbar-inner"></div>
</div>
<q-table <q-table
v-if="activeTab === 'general'" v-if="activeTab === 'general'"
flat flat
bordered bordered
row-key="row_key" 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" :rows="filteredGeneralRows"
:columns="generalColumns" :columns="visibleGeneralColumns"
:loading="loading" :loading="activeTableLoading"
:pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }" v-model:pagination="tablePagination.general"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -176,7 +199,7 @@
v-if="getCachedProductImageUrl(props.row)" v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)"
class="product-thumb" class="product-thumb"
@error="markProductImageFailed(props.row)" @error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt="" alt=""
@@ -205,11 +228,12 @@
class="performance-table sticky-dim-table sticky-dim-8 bg-white" class="performance-table sticky-dim-table sticky-dim-8 bg-white"
:rows="displayOrderProductCustomerTableRows" :rows="displayOrderProductCustomerTableRows"
:columns="orderProductCustomerColumns" :columns="orderProductCustomerColumns"
:loading="loading || backendGroupedLoading" :loading="activeTableLoading"
:pagination="{ rowsPerPage: 0, sortBy: 'order_usd', descending: true }" v-model:pagination="tablePagination.order_product_customers"
:sort-method="sortProductGroupedTableRows" :sort-method="sortProductGroupedTableRows"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -288,7 +312,7 @@
v-if="getCachedProductImageUrl(props.row)" v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)"
class="product-thumb" class="product-thumb"
@error="markProductImageFailed(props.row)" @error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt="" alt=""
@@ -321,7 +345,7 @@
<q-tr <q-tr
v-if="props.row.__group" v-if="props.row.__group"
:props="props" :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)"> <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)"> <template v-if="col.name === 'image' && groupShowsImage(props.row)">
@@ -330,7 +354,7 @@
v-if="getCachedProductImageUrl(props.row)" v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)"
class="product-thumb" class="product-thumb"
@error="markProductImageFailed(props.row)" @error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt="" alt=""
@@ -351,7 +375,8 @@
dense dense
round round
size="sm" 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)" @click.stop="toggleGroup(props.row.key)"
/> />
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span> <span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
@@ -367,7 +392,7 @@
v-if="getCachedProductImageUrl(props.row)" v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)"
class="product-thumb" class="product-thumb"
@error="markProductImageFailed(props.row)" @error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt="" alt=""
@@ -395,11 +420,12 @@
class="performance-table sticky-dim-table sticky-dim-10 bg-white" class="performance-table sticky-dim-table sticky-dim-10 bg-white"
:rows="displayOrderMarketDetailTableRows" :rows="displayOrderMarketDetailTableRows"
:columns="orderMarketDetailColumns" :columns="orderMarketDetailColumns"
:loading="loading || backendGroupedLoading" :loading="activeTableLoading"
:pagination="{ rowsPerPage: 0, sortBy: 'order_date', descending: true }" v-model:pagination="tablePagination.order_market_details"
:sort-method="sortProductGroupedTableRows" :sort-method="sortProductGroupedTableRows"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -492,7 +518,7 @@
<q-tr <q-tr
v-if="props.row.__group" v-if="props.row.__group"
:props="props" :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)"> <q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<div <div
@@ -505,7 +531,8 @@
dense dense
round round
size="sm" 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)" @click.stop="toggleGroup(props.row.key)"
/> />
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span> <span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
@@ -525,18 +552,25 @@
</q-table> </q-table>
<q-table <q-table
v-else-if="activeTab === 'products'" v-else-if="isProductKpiTab"
flat flat
bordered bordered
row-key="row_key" row-key="row_key"
class="performance-table product-breakdown-table bg-white" :class="[
:rows="displayProductTableRows" 'performance-table',
:columns="productColumns" 'product-breakdown-table',
:loading="loading || backendGroupedLoading" 'bg-white',
:pagination="pagination" { 'compact-detail-columns': detailColumnsHidden },
activeTab === 'product_detail' ? 'product-detail-table' : 'product-summary-table'
]"
:rows="displayProductKpiTableRows"
:columns="activeProductColumns"
:loading="activeTableLoading"
v-model:pagination="activeProductPagination"
:sort-method="sortProductGroupedTableRows" :sort-method="sortProductGroupedTableRows"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -549,17 +583,17 @@
round round
size="8px" size="8px"
icon="filter_alt" icon="filter_alt"
:color="hasColumnFilter('products', props.col.name) ? 'primary' : 'grey-7'" :color="hasColumnFilter(activeProductTableKey, props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn" class="header-filter-btn"
@click.stop @click.stop
> >
<q-badge v-if="hasColumnFilter('products', props.col.name)" color="primary" floating rounded> <q-badge v-if="hasColumnFilter(activeProductTableKey, props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('products', props.col.name) }} {{ getColumnFilterBadgeValue(activeProductTableKey, props.col.name) }}
</q-badge> </q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]"> <q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu"> <div class="excel-filter-menu">
<q-input <q-input
v-model="columnFilterSearch[filterKey('products', props.col.name)]" v-model="columnFilterSearch[filterKey(activeProductTableKey, props.col.name)]"
dense dense
outlined outlined
clearable clearable
@@ -567,13 +601,13 @@
placeholder="Ara" placeholder="Ara"
/> />
<div class="excel-filter-actions row items-center justify-between q-pt-xs"> <div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions('products', props.col.name)" /> <q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions(activeProductTableKey, props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter('products', props.col.name)" /> <q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter(activeProductTableKey, props.col.name)" />
</div> </div>
<q-virtual-scroll <q-virtual-scroll
v-if="columnFilterOptions('products', props.col.name).length > 0" v-if="columnFilterOptions(activeProductTableKey, props.col.name).length > 0"
class="excel-filter-options" class="excel-filter-options"
:items="columnFilterOptions('products', props.col.name)" :items="columnFilterOptions(activeProductTableKey, props.col.name)"
:virtual-scroll-item-size="32" :virtual-scroll-item-size="32"
separator separator
> >
@@ -583,14 +617,14 @@
dense dense
clickable clickable
class="excel-filter-option" class="excel-filter-option"
@click="toggleColumnFilterValue('products', props.col.name, option.value)" @click="toggleColumnFilterValue(activeProductTableKey, props.col.name, option.value)"
> >
<q-item-section avatar> <q-item-section avatar>
<q-checkbox <q-checkbox
dense dense
size="sm" size="sm"
:model-value="isColumnFilterValueSelected('products', props.col.name, option.value)" :model-value="isColumnFilterValueSelected(activeProductTableKey, props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('products', props.col.name, option.value)" @update:model-value="() => toggleColumnFilterValue(activeProductTableKey, props.col.name, option.value)"
@click.stop @click.stop
/> />
</q-item-section> </q-item-section>
@@ -613,7 +647,7 @@
<q-tr <q-tr
v-if="props.row.__group" v-if="props.row.__group"
:props="props" :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)"> <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)"> <template v-if="col.name === 'image' && groupShowsImage(props.row)">
@@ -622,7 +656,7 @@
v-if="getCachedProductImageUrl(props.row)" v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)"
class="product-thumb" class="product-thumb"
@error="markProductImageFailed(props.row)" @error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt="" alt=""
@@ -643,7 +677,8 @@
dense dense
round round
size="sm" 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)" @click.stop="toggleGroup(props.row.key)"
/> />
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span> <span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
@@ -659,7 +694,7 @@
v-if="getCachedProductImageUrl(props.row)" v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)"
class="product-thumb" class="product-thumb"
@error="markProductImageFailed(props.row)" @error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt="" alt=""
@@ -687,11 +722,12 @@
class="performance-table sticky-dim-table sticky-dim-11 bg-white" class="performance-table sticky-dim-table sticky-dim-11 bg-white"
:rows="displayIdleTableRows" :rows="displayIdleTableRows"
:columns="idleColumns" :columns="idleColumns"
:loading="loading || backendGroupedLoading" :loading="activeTableLoading"
:pagination="{ rowsPerPage: 0 }" v-model:pagination="tablePagination.idle"
:sort-method="sortProductGroupedTableRows" :sort-method="sortProductGroupedTableRows"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -770,7 +806,7 @@
v-if="getCachedProductImageUrl(props.row)" v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)"
class="product-thumb" class="product-thumb"
@error="markProductImageFailed(props.row)" @error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt="" alt=""
@@ -799,7 +835,7 @@
<q-tr <q-tr
v-if="props.row.__group" v-if="props.row.__group"
:props="props" :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)"> <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)"> <template v-if="col.name === 'image' && groupShowsImage(props.row)">
@@ -808,7 +844,7 @@
v-if="getCachedProductImageUrl(props.row)" v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)"
class="product-thumb" class="product-thumb"
@error="markProductImageFailed(props.row)" @error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt="" alt=""
@@ -829,7 +865,8 @@
dense dense
round round
size="sm" 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)" @click.stop="toggleGroup(props.row.key)"
/> />
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span> <span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
@@ -845,7 +882,7 @@
v-if="getCachedProductImageUrl(props.row)" v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)" :src="getCachedProductImageUrl(props.row)"
class="product-thumb" class="product-thumb"
@error="markProductImageFailed(props.row)" @error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt="" alt=""
@@ -873,10 +910,11 @@
class="performance-table sticky-dim-table sticky-dim-9 bg-white" class="performance-table sticky-dim-table sticky-dim-9 bg-white"
:rows="filteredMarketRows" :rows="filteredMarketRows"
:columns="marketColumns" :columns="marketColumns"
:loading="loading" :loading="activeTableLoading"
:pagination="{ rowsPerPage: 0 }" v-model:pagination="tablePagination.markets"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -970,14 +1008,21 @@
flat flat
bordered bordered
row-key="row_key" row-key="row_key"
class="performance-table sticky-dim-table sticky-dim-9 bg-white" :class="[
'performance-table',
'sticky-dim-table',
salesBreakdownStickyClass,
activeTab === 'sales_color_yaka_market_customer' ? 'sales-color-yaka-table' : '',
'bg-white'
]"
:rows="displayActiveSalesBreakdownTableRows" :rows="displayActiveSalesBreakdownTableRows"
:columns="visibleSalesBreakdownColumns" :columns="visibleSalesBreakdownColumns"
:loading="loading || backendGroupedLoading" :loading="activeTableLoading"
:pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }" v-model:pagination="activeSalesBreakdownPagination"
:sort-method="sortProductGroupedTableRows" :sort-method="sortProductGroupedTableRows"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -1075,7 +1120,7 @@
<q-tr <q-tr
v-if="props.row.__group" v-if="props.row.__group"
:props="props" :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)"> <q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<div <div
@@ -1088,7 +1133,8 @@
dense dense
round round
size="sm" 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)" @click.stop="toggleGroup(props.row.key)"
/> />
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span> <span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
@@ -1131,10 +1177,11 @@
class="performance-table sticky-dim-table sticky-dim-5 bg-white" class="performance-table sticky-dim-table sticky-dim-5 bg-white"
:rows="filteredCustomerRows" :rows="filteredCustomerRows"
:columns="customerColumns" :columns="customerColumns"
:loading="loading" :loading="activeTableLoading"
:pagination="{ rowsPerPage: 0 }" v-model:pagination="tablePagination.customers"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -1232,10 +1279,11 @@
class="performance-table sticky-dim-table sticky-dim-3 bg-white" class="performance-table sticky-dim-table sticky-dim-3 bg-white"
:rows="filteredCountryRows" :rows="filteredCountryRows"
:columns="countryColumns" :columns="countryColumns"
:loading="loading" :loading="activeTableLoading"
:pagination="{ rowsPerPage: 0 }" v-model:pagination="tablePagination.countries"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
<q-th :props="props" class="filterable-header-cell"> <q-th :props="props" class="filterable-header-cell">
@@ -1371,7 +1419,7 @@
:name="idx" :name="idx"
class="column no-wrap flex-center" 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-slide>
</q-carousel> </q-carousel>
<div v-else class="product-image-empty"> <div v-else class="product-image-empty">
@@ -1472,16 +1520,17 @@
</template> </template>
<script setup> <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 { Notify } from 'quasar'
import api from 'src/services/api' import api, { extractApiErrorDetail } from 'src/services/api'
import { useProductPerformanceStore } from 'src/stores/productPerformanceStore' import { useProductPerformanceStore } from 'src/stores/productPerformanceStore'
const performanceStore = useProductPerformanceStore() const performanceStore = useProductPerformanceStore()
function formatNumber (value, fraction = 2) { function formatNumber (value, fraction = 2) {
const n = Number(value || 0) 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', { return n.toLocaleString('tr-TR', {
minimumFractionDigits: digits, minimumFractionDigits: digits,
maximumFractionDigits: digits maximumFractionDigits: digits
@@ -1497,7 +1546,9 @@ function formatPercent (value) {
} }
const loading = ref(false) const loading = ref(false)
const excelExportLoading = ref(false)
const rows = ref([]) const rows = ref([])
const detailMainGroupOptionRows = ref([])
const generalRows = ref([]) const generalRows = ref([])
const orderAnalysisRows = ref([]) const orderAnalysisRows = ref([])
const orderMarketRows = ref([]) const orderMarketRows = ref([])
@@ -1507,6 +1558,9 @@ const orderMarketDetailRows = ref([])
const orderAnalysisLoaded = ref(false) const orderAnalysisLoaded = ref(false)
const orderProductCustomersLoaded = ref(false) const orderProductCustomersLoaded = ref(false)
const orderMarketDetailsLoaded = ref(false) const orderMarketDetailsLoaded = ref(false)
const marketRowsLoaded = ref(false)
const countryRowsLoaded = ref(false)
const customerRowsLoaded = ref(false)
const orderAnalysisMode = ref('product') const orderAnalysisMode = ref('product')
const marketRows = ref([]) const marketRows = ref([])
const countryRows = ref([]) const countryRows = ref([])
@@ -1521,8 +1575,11 @@ const summary = ref({})
const activeTab = ref('products') const activeTab = ref('products')
const customerBreakdown = ref('market_customer') const customerBreakdown = ref('market_customer')
const selectedPeriods = ref(['90', '180', '360', 'total']) const selectedPeriods = ref(['90', '180', '360', 'total'])
const selectedDetailMainGroup = ref('')
const imageUrlByKey = ref({}) const imageUrlByKey = ref({})
const imageListByKey = ref({}) const imageListByKey = ref({})
const imageFailedKeys = ref(new Set())
const imageLoadingKeys = ref(new Set())
const imageDialog = ref(false) const imageDialog = ref(false)
const imageDialogUrls = ref([]) const imageDialogUrls = ref([])
const imageDialogTitle = ref('') const imageDialogTitle = ref('')
@@ -1537,46 +1594,151 @@ const detailLoading = ref(false)
const detailSalesRows = ref([]) const detailSalesRows = ref([])
const detailStockSizes = ref([]) const detailStockSizes = ref([])
const backendGroupedRows = ref({}) const backendGroupedRows = ref({})
const backendGroupedFilterOptions = ref({})
const backendGroupedLoading = ref(false) const backendGroupedLoading = ref(false)
const imagePrimeLoading = ref(false) const imagePrimeLoading = ref(false)
const filterBusy = 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 backendGroupedTimer = null
let imagePrimeActiveRequests = 0 let imagePrimeActiveRequests = 0
let activeTableMiddleEl = null
let topScrollbarResizeObserver = null
let topScrollbarSyncing = false
let loadingTimer = null
const columnFilters = reactive({}) const columnFilters = reactive({})
const columnFilterSearch = reactive({}) const columnFilterSearch = reactive({})
const expandedGroups = ref({}) const expandedGroups = ref({})
const lastManualExpandedGroupKey = ref('') const lastManualExpandedGroupKey = ref('')
const selectedExpandLevelKeysByTab = reactive({ const selectedExpandLevelKeysByTab = reactive({
products: ['urun_ilk_grubu', 'askili_yan', 'kategori', 'urun_ana_grubu', 'urun_alt_grubu'], products: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
sales_color_yaka_market_customer: ['color_code', 'yaka_kodu', 'urun_ilk_grubu', 'askili_yan'], product_detail: ['urun_alt_grubu'],
idle: ['urun_ilk_grubu', 'askili_yan', 'kategori', 'urun_ana_grubu', 'urun_alt_grubu'], sales_color_yaka_market_customer: ['urun_ana_grubu', 'color_yaka'],
sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'], 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_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'], sales_country_segment_market_customer_product: ['country', 'customer_segment', 'market_key'],
order_product_customers: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'], order_product_customers: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
order_market_details: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'] order_market_details: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan']
}) })
const productKpiFetchLimit = 50000 const productKpiFetchLimit = 50000
const reportFetchLimit = 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 = [ const performanceTabs = [
{ name: 'products', icon: 'inventory_2', label: 'Ürün KPI' }, { name: 'products', icon: 'dashboard', label: 'Genel Özet 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: '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_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: '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_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ş' } { 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 pageBusy = computed(() => loading.value)
const activeTableLoading = computed(() => loading.value || (backendGroupedSupportedTab(activeTab.value) && backendGroupedLoading.value))
const pagination = ref({ const loadingElapsedLabel = computed(() => {
page: 1, if (!loadingStartedAt.value || !loadingNow.value) return ''
rowsPerPage: 0, const seconds = Math.max(0, Math.floor((loadingNow.value - loadingStartedAt.value) / 1000))
sortBy: 'performance_score_total', const minutes = Math.floor(seconds / 60)
descending: true 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 = [ const bucketOptions = [
{ label: 'Yıldız Ürün', value: 'YILDIZ_URUN' }, { label: 'Yıldız Ürün', value: 'YILDIZ_URUN' },
@@ -1621,9 +1783,9 @@ const detailSalesColumns = [
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true }, { name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true }, { name: 'customer_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: '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: '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 = [ const columns = [
@@ -1639,13 +1801,13 @@ const columns = [
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' }, { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true }, { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }, { name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'performance_score_90d', label: '90G Skor', field: row => formatNumber(row.performance_score_90d, 1), align: 'right', sortable: true }, { name: 'performance_score_90d', label: '90G Skor', field: row => formatNumber(row.performance_score_90d, 2), align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, { name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'performance_score_180d', label: '180G Skor', field: row => formatNumber(row.performance_score_180d, 1), align: 'right', sortable: true }, { name: 'performance_score_180d', label: '180G Skor', field: row => formatNumber(row.performance_score_180d, 2), align: 'right', sortable: true },
{ name: 'sales_qty_180d', label: '180G Satış', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true }, { name: 'sales_qty_180d', label: '180G Satış', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true },
{ name: 'performance_score_365d', label: '360G Skor', field: row => formatNumber(row.performance_score_365d, 1), align: 'right', sortable: true }, { name: 'performance_score_365d', label: '360G Skor', field: row => formatNumber(row.performance_score_365d, 2), align: 'right', sortable: true },
{ name: 'sales_qty_365d', label: '360G Satış', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true }, { name: 'sales_qty_365d', label: '360G Satış', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
{ name: 'performance_score_total', label: 'Genel Skor', field: row => formatNumber(row.performance_score_total, 1), align: 'right', sortable: true }, { name: 'performance_score_total', label: 'Genel Skor', field: row => formatNumber(row.performance_score_total, 2), align: 'right', sortable: true },
{ name: 'sales_qty_total', label: 'Genel Satış', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true }, { name: 'sales_qty_total', label: 'Genel Satış', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'sales_usd_180d', label: '180G USD', field: 'sales_usd_180d', align: 'right', sortable: true }, { name: 'sales_usd_180d', label: '180G USD', field: 'sales_usd_180d', align: 'right', sortable: true },
@@ -1655,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_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_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_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_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 Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 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 Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 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 Stok Devir', field: row => formatNumber(row.stock_turnover_total, 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: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' }, { 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. USD', field: row => formatMoney(row.avg_price_usd_180d, '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. USD', field: row => formatMoney(row.avg_price_usd_365d, '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. USD', field: row => formatMoney(row.avg_price_usd_total, '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: 'Ort. USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, '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: 'Ort. USD Çıplak Maliyeti', field: row => formatMoney(row.cost_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_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_cost_90d', label: '90G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_90d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true },
@@ -1674,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_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_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_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_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_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 }, { name: 'gross_margin_base_180d', label: '180G Taban Marj', field: row => formatPercent(row.gross_margin_base_180d), align: 'right', sortable: true },
@@ -1686,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: '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_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: '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, 1), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' }, { name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
] ]
@@ -1705,7 +1868,23 @@ const periodOrder = {
total: 4 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 = [ const dimensionColumnNames = [
'product_code', 'product_code',
'color_yaka', 'color_yaka',
@@ -1842,6 +2021,35 @@ function reorderColumnsInPlace (targetColumns) {
} }
const productColumns = computed(() => orderedMetricColumns(columns.filter(visiblePeriodColumn))) const productColumns = computed(() => orderedMetricColumns(columns.filter(visiblePeriodColumn)))
const productDetailColumns = computed(() => orderedMetricColumns(columns
.filter(col => !['urun_ilk_grubu', 'askili_yan', 'kategori'].includes(col.name))
.filter(visiblePeriodColumn)))
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 = [ const generalColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' }, { name: 'image', label: 'Foto', field: 'image', align: 'center' },
@@ -1858,29 +2066,33 @@ const generalColumns = [
{ name: 'period_start', label: 'Başlangıç', field: 'period_start', align: 'left', sortable: true }, { 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: '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: '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_qty_total', label: 'Toplam Adet', 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_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: '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: '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: '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: '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_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: '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_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_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: '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: '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: '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: '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: '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, 1), 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: '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: '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: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
] ]
const visibleGeneralColumns = computed(() => applyDetailColumnVisibility(generalColumns))
const orderAnalysisColumns = [ const orderAnalysisColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' }, { name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
@@ -1923,7 +2135,7 @@ const orderGroupColumns = [
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true }, { 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_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: '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: '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: '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 }, { name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
@@ -1957,7 +2169,7 @@ const orderProductCustomerColumns = [
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true }, { 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_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: '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: '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: '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 }, { name: 'base_price_usd', label: 'Taban USD', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
@@ -1991,7 +2203,7 @@ const orderMarketDetailColumns = [
{ name: 'due_date', label: 'Termin', field: 'due_date', align: 'left', sortable: true }, { 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_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: '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: '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: '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 }, { name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
@@ -2020,7 +2232,7 @@ const idleColumns = [
{ name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true }, { 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: '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_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: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
] ]
@@ -2051,7 +2263,7 @@ const countryColumns = [
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true }, { 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_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: '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: '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: '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 }, { name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
@@ -2067,8 +2279,8 @@ const customerColumns = [
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true }, { 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_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: '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, 1), 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: '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 }, { name: 'cost_price_usd_90d', label: '90G Çıplak', field: row => formatMoney(row.cost_price_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_90d', label: '90G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_90d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_90d', label: '90G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_90d, 'USD'), align: 'right', sortable: true },
@@ -2102,66 +2314,167 @@ const salesBreakdownColumns = [
{ name: 'invoice_count_90d', label: '90G Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true }, { 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_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: '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: '90G Ort. Satış Fiyatı 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: '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: '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: '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_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: '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: '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 Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 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: 'base_price_usd_180d', label: '180G Taban', field: row => formatMoney(row.base_price_usd_180d, 'USD'), 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: '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 }, { 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 },
{ name: 'gross_profit_cost_usd_180d', label: '180G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_cost_usd_180d', label: '180G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_180d, 'USD'), 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 }, { name: 'gross_margin_base_180d', label: '180G Taban Marj', field: row => formatPercent(row.gross_margin_base_180d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_180d', label: '180G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_180d), align: 'right', sortable: true }, { name: 'gross_margin_cost_180d', label: '180G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_180d), align: 'right', sortable: true },
{ name: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 1), align: 'right', sortable: true }, { 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_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: '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: '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 Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 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: 'base_price_usd_365d', label: '360G Taban', field: row => formatMoney(row.base_price_usd_365d, 'USD'), 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: '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 }, { 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 },
{ name: 'gross_profit_cost_usd_365d', label: '360G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_cost_usd_365d', label: '360G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_base_365d', label: '360G Taban Marj', field: row => formatPercent(row.gross_margin_base_365d), align: 'right', sortable: true }, { name: 'gross_margin_base_365d', label: '360G Taban Marj', field: row => formatPercent(row.gross_margin_base_365d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_365d', label: '360G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_365d), align: 'right', sortable: true }, { name: 'gross_margin_cost_365d', label: '360G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_365d), align: 'right', sortable: true },
{ name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 1), align: 'right', sortable: true }, { name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 2), align: 'right', sortable: true },
{ name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true }, { name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'invoice_count_total', label: 'Genel Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true }, { 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_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: '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: '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 Stok Devir', field: row => formatNumber(row.stock_turnover_total, 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: 'base_price_usd_total', label: 'Genel Taban', field: row => formatMoney(row.base_price_usd_total, 'USD'), 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: '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 }, { 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_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_total', label: 'Genel Taban Marj', field: row => formatPercent(row.gross_margin_base_total), 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: '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, 1), 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: '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, 1), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', 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: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
] ]
const productPeriodScoreColumns = [ const productPeriodScoreColumns = [
{ name: 'performance_score_90d', label: '90G Ürün Skor', field: row => formatNumber(row.performance_score_90d, 1), align: 'right', sortable: true }, { name: 'performance_score_90d', label: '90G Ürün Skor', field: row => formatNumber(row.performance_score_90d, 2), align: 'right', sortable: true },
{ name: 'performance_score_180d', label: '180G Ürün Skor', field: row => formatNumber(row.performance_score_180d, 1), align: 'right', sortable: true }, { name: 'performance_score_180d', label: '180G Ürün Skor', field: row => formatNumber(row.performance_score_180d, 2), align: 'right', sortable: true },
{ name: 'performance_score_365d', label: '360G Ürün Skor', field: row => formatNumber(row.performance_score_365d, 1), align: 'right', sortable: true }, { name: 'performance_score_365d', label: '360G Ürün Skor', field: row => formatNumber(row.performance_score_365d, 2), align: 'right', sortable: true },
{ name: 'performance_score_total', label: 'Genel Ürün Skor', field: row => formatNumber(row.performance_score_total, 1), align: 'right', sortable: true } { name: 'performance_score_total', label: 'Genel Ürün Skor', field: row => formatNumber(row.performance_score_total, 2), align: 'right', sortable: true }
] ]
const customerPeriodScoreColumns = [ const customerPeriodScoreColumns = [
{ name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 1), 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: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 1), align: 'right', sortable: true }, { name: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 2), align: 'right', sortable: true },
{ name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 1), align: 'right', sortable: true }, { name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 2), align: 'right', sortable: true },
{ name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 1), 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 }
] ]
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) { function ensureColumns (targetColumns, columnsToEnsure) {
const existing = new Set(targetColumns.map(col => col.name)) const existing = new Set(targetColumns.map(col => col.name))
for (const col of columnsToEnsure) { for (const col of columnsToEnsure) {
@@ -2225,7 +2538,6 @@ function ensureColorYakaColumn (targetColumns) {
].forEach(target => ensureColumns(target, customerPeriodScoreColumns)) ].forEach(target => ensureColumns(target, customerPeriodScoreColumns))
;[ ;[
generalColumns,
orderAnalysisColumns, orderAnalysisColumns,
orderGroupColumns, orderGroupColumns,
orderProductCustomerColumns, orderProductCustomerColumns,
@@ -2257,7 +2569,27 @@ function ensureColorYakaColumn (targetColumns) {
attachColumnSorts(detailStockColumns) attachColumnSorts(detailStockColumns)
attachColumnSorts(detailSalesColumns) attachColumnSorts(detailSalesColumns)
const visibleSalesBreakdownColumns = computed(() => orderedMetricColumns(salesBreakdownColumns.filter(visiblePeriodColumn))) const visibleSalesBreakdownColumns = computed(() => {
const base = orderedMetricColumns(salesBreakdownColumns.filter(visiblePeriodColumn))
if (activeTab.value !== 'sales_color_yaka_market_customer') return base
const priority = ['performance_bucket', 'recommendation']
const pulled = priority
.map(name => base.find(col => col.name === name))
.filter(Boolean)
const rest = base.filter(col => !priority.includes(col.name))
const insertAfter = Math.max(
rest.findIndex(col => col.name === 'item_description'),
rest.findIndex(col => col.name === 'product_code')
)
if (insertAfter < 0) return [...pulled, ...rest]
return [
...rest.slice(0, insertAfter + 1),
...pulled,
...rest.slice(insertAfter + 1)
]
})
const salesBreakdownStickyClass = computed(() => activeTab.value === 'sales_color_yaka_market_customer' ? 'sticky-dim-9' : 'sticky-dim-9')
const summaryCards = computed(() => [ const summaryCards = computed(() => [
{ key: 'date', label: 'KPI Tarihi', value: summary.value.kpi_date || '-' }, { key: 'date', label: 'KPI Tarihi', value: summary.value.kpi_date || '-' },
@@ -2331,13 +2663,16 @@ const visibleOrderAnalysisColumns = computed(() => {
function performanceCardsForRow (row) { function performanceCardsForRow (row) {
const source = row || {} const source = row || {}
return [ return [
{ key: 'score', label: 'Skor', value: formatNumber(source.performance_score, 1) }, { 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: 'stock', label: 'Stok', value: formatNumber(source.stock_qty, 0) },
{ key: 'sales90', label: '90G Satış', value: formatNumber(source.sales_qty_90d, 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: 'sales180', label: '180G Satış', value: formatNumber(source.sales_qty_180d, 0) },
{ key: 'markets', label: '90G Piyasa', value: formatNumber(source.market_count_90d, 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: '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: 'base', label: 'Taban Maliyet', value: formatMoney(source.base_price_usd, 'USD') },
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(source.cost_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') }, { key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(source.unit_profit_base_90d, 'USD') },
@@ -2379,27 +2714,26 @@ const groupLevels = [
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }, { key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' }, { key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'yaka_kodu', label: 'Yaka' },
{ key: 'market_key', label: 'Piyasa' } { key: 'market_key', label: 'Piyasa' }
] ]
const tabGroupLevels = { const tabGroupLevels = {
products: groupLevels, products: groupLevels,
sales_color_yaka_market_customer: [ product_detail: [
{ key: 'color_code', label: 'Renk' },
{ key: 'yaka_kodu', label: '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: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }, { key: 'product_code', label: 'Ürün' },
{ key: 'country', label: 'Ülke' }, { key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'market_key', label: 'Piyasa' }
],
sales_color_yaka_market_customer: [
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'market_key', label: 'Piyasa' }, { key: 'market_key', label: 'Piyasa' },
{ key: 'customer_segment', label: 'Segment' },
{ key: 'customer_code', label: 'Müşteri Kodu' }, { 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: [ idle: [
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' }, { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
@@ -2408,21 +2742,21 @@ const tabGroupLevels = {
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }, { key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' }, { key: 'color_yaka', label: 'Renk/Yaka' }
{ key: 'yaka_kodu', label: 'Yaka' }
], ],
sales_product_country_segment_market_customer: [ 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: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' }, { key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' }, { key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }, { key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' }, { key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'yaka_kodu', label: '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: [ sales_market_customer_product: [
{ key: 'market_key', label: 'Piyasa' }, { key: 'market_key', label: 'Piyasa' },
@@ -2434,10 +2768,11 @@ const tabGroupLevels = {
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }, { key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' }, { key: 'color_yaka', label: 'Renk/Yaka' }
{ key: 'yaka_kodu', label: 'Yaka' }
], ],
sales_country_segment_market_customer_product: [ sales_country_segment_market_customer_product: [
{ key: 'country', label: 'Ülke' },
{ key: 'customer_segment', label: 'Segment' },
{ key: 'market_key', label: 'Piyasa' }, { key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' }, { key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' }, { key: 'customer_name', label: 'Müşteri' },
@@ -2447,21 +2782,19 @@ const tabGroupLevels = {
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }, { key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' }, { key: 'color_yaka', label: 'Renk/Yaka' }
{ key: 'yaka_kodu', label: 'Yaka' }
], ],
order_product_customers: [ 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: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' }, { key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' }, { key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }, { key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' }, { key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'yaka_kodu', label: 'Yaka' } { key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' }
], ],
order_market_details: [ order_market_details: [
{ key: 'market_key', label: 'Piyasa' }, { key: 'market_key', label: 'Piyasa' },
@@ -2473,8 +2806,7 @@ const tabGroupLevels = {
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }, { key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' }, { key: 'color_yaka', label: 'Renk/Yaka' }
{ key: 'yaka_kodu', label: 'Yaka' }
] ]
} }
@@ -2500,27 +2832,38 @@ const selectedExpandThroughLevel = computed(() => {
return indexes.length ? Math.max(...indexes) : -1 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 autoExpandThroughLevel = computed(() => {
const selectedLevel = selectedExpandThroughLevel.value const selectedLevel = selectedExpandThroughLevel.value
return selectedLevel >= 0 ? selectedLevel : activeGroupLevels.value.length - 1 const lastGroupLevel = Math.max(0, activeGroupLevels.value.length - 2)
const requestedLevel = selectedLevel >= 0 ? selectedLevel : 2
return Math.min(requestedLevel, lastGroupLevel, maxAutoExpandThroughLevelForTab())
}) })
const tableFilterSourceMap = computed(() => ({ const fullExpandThroughLevel = computed(() => {
products: { rows: rows.value, columns: productColumns.value }, const lastGroupLevel = Math.max(0, activeGroupLevels.value.length - 2)
general: { rows: generalRows.value, columns: generalColumns }, return Math.min(lastGroupLevel, maxAutoExpandThroughLevelForTab())
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 filteredProductRows = computed(() => filterRowsForTable('products', rows.value, productColumns.value)) const filteredProductRows = computed(() => filterRowsForTable('products', rows.value, productColumns.value))
const isProductKpiTab = computed(() => activeTab.value === 'products' || activeTab.value === 'product_detail')
const activeProductTableKey = computed(() => activeTab.value === 'product_detail' ? 'product_detail' : 'products')
const detailProductRows = computed(() => {
const selected = String(selectedDetailMainGroup.value || '').trim()
if (!selected) return rows.value
return rows.value.filter(row => productMainGroupValue(row) === selected)
})
const filteredDetailProductRows = computed(() => filterRowsForTable('product_detail', detailProductRows.value, productDetailColumns.value))
const filteredGeneralRows = computed(() => filterRowsForTable('general', generalRows.value, generalColumns)) const filteredGeneralRows = computed(() => filterRowsForTable('general', generalRows.value, generalColumns))
const filteredOrderProductCustomerRows = computed(() => filterRowsForTable('order_product_customers', orderProductCustomerRows.value, orderProductCustomerColumns)) const filteredOrderProductCustomerRows = computed(() => filterRowsForTable('order_product_customers', orderProductCustomerRows.value, orderProductCustomerColumns))
const filteredOrderMarketDetailRows = computed(() => filterRowsForTable('order_market_details', orderMarketDetailRows.value, orderMarketDetailColumns)) const filteredOrderMarketDetailRows = computed(() => filterRowsForTable('order_market_details', orderMarketDetailRows.value, orderMarketDetailColumns))
@@ -2531,6 +2874,7 @@ const filteredCustomerRows = computed(() => filterRowsForTable('customers', cust
const filteredActiveSalesBreakdownRows = computed(() => filterRowsForTable(activeTab.value, salesBreakdownRows[activeTab.value] || [], visibleSalesBreakdownColumns.value)) const filteredActiveSalesBreakdownRows = computed(() => filterRowsForTable(activeTab.value, salesBreakdownRows[activeTab.value] || [], visibleSalesBreakdownColumns.value))
const activeGroupSourceRows = computed(() => { const activeGroupSourceRows = computed(() => {
if (activeTab.value === 'products') return filteredProductRows.value if (activeTab.value === 'products') return filteredProductRows.value
if (activeTab.value === 'product_detail') return filteredDetailProductRows.value
if (activeTab.value === 'idle') return filteredIdleRows.value if (activeTab.value === 'idle') return filteredIdleRows.value
if (activeTab.value === 'order_product_customers') return filteredOrderProductCustomerRows.value if (activeTab.value === 'order_product_customers') return filteredOrderProductCustomerRows.value
if (activeTab.value === 'order_market_details') return filteredOrderMarketDetailRows.value if (activeTab.value === 'order_market_details') return filteredOrderMarketDetailRows.value
@@ -2540,43 +2884,123 @@ const activeGroupSourceRows = computed(() => {
const columnFilterOptionMap = computed(() => { const columnFilterOptionMap = computed(() => {
const out = {} const out = {}
for (const [tableKey, source] of Object.entries(tableFilterSourceMap.value)) { const tableKey = isProductKpiTab.value ? activeProductTableKey.value : activeTab.value
out[tableKey] = {} const source = tableFilterSource(tableKey)
for (const col of source.columns) { if (!source) return out
if (!isColumnFilterable(col.name)) continue out[tableKey] = {}
const seen = new Map() const preparedOptionEntry = backendGroupedSupportedTab(tableKey)
for (const row of source.rows) { ? backendGroupedFilterOptions.value[tableKey]
const value = columnFilterValue(row, col) : null
if (!value) continue const preparedOptions = preparedOptionEntry?._key === backendGroupedFilterOptionsKey(tableKey)
if (!seen.has(value)) { ? preparedOptionEntry.options || {}
seen.set(value, { : {}
label: columnFilterLabel(row, col), for (const col of source.columns) {
value if (!isColumnFilterable(col.name)) continue
}) if (backendGroupedSupportedTab(tableKey) && backendGroupedFilterFields.has(col.name)) {
} out[tableKey][col.name] = preparedOptions[col.name] || []
} continue
out[tableKey][col.name] = Array.from(seen.values())
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'tr', { numeric: true }))
} }
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 return out
}) })
const productTableRows = computed(() => { const productTableRows = computed(() => {
if (shouldUseBackendGroupedRows('products')) return []
const out = [] const out = []
appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels) appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels, 'products')
return out return out
}) })
const detailProductTableRows = computed(() => shouldUseBackendGroupedRows('product_detail') ? [] : buildGroupedTableRows('product_detail', filteredDetailProductRows.value))
const idleTableRows = computed(() => buildGroupedTableRows('idle', filteredIdleRows.value)) const idleTableRows = computed(() => shouldUseBackendGroupedRows('idle') ? [] : buildGroupedTableRows('idle', filteredIdleRows.value))
const orderProductCustomerTableRows = computed(() => buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value)) const orderProductCustomerTableRows = computed(() => shouldUseBackendGroupedRows('order_product_customers') ? [] : buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value))
const orderMarketDetailTableRows = computed(() => buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value)) const orderMarketDetailTableRows = computed(() => shouldUseBackendGroupedRows('order_market_details') ? [] : buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value))
const activeSalesBreakdownTableRows = computed(() => buildGroupedTableRows(activeTab.value, filteredActiveSalesBreakdownRows.value)) const activeSalesBreakdownTableRows = computed(() => shouldUseBackendGroupedRows(activeTab.value) ? [] : buildGroupedTableRows(activeTab.value, filteredActiveSalesBreakdownRows.value))
const displayProductTableRows = computed(() => backendRowsForTab('products', productTableRows.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)
const displayIdleTableRows = computed(() => backendRowsForTab('idle', idleTableRows.value)) const displayIdleTableRows = computed(() => backendRowsForTab('idle', idleTableRows.value))
const displayOrderProductCustomerTableRows = computed(() => backendRowsForTab('order_product_customers', orderProductCustomerTableRows.value)) const displayOrderProductCustomerTableRows = computed(() => backendRowsForTab('order_product_customers', orderProductCustomerTableRows.value))
const displayOrderMarketDetailTableRows = computed(() => backendRowsForTab('order_market_details', orderMarketDetailTableRows.value)) const displayOrderMarketDetailTableRows = computed(() => backendRowsForTab('order_market_details', orderMarketDetailTableRows.value))
const displayActiveSalesBreakdownTableRows = computed(() => backendRowsForTab(activeTab.value, activeSalesBreakdownTableRows.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, _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 productGroupKeys = computed(() => {
const keys = [] const keys = []
@@ -2591,17 +3015,16 @@ const productAutoExpandKeys = computed(() => {
}) })
const allProductGroupsExpanded = computed(() => { const allProductGroupsExpanded = computed(() => {
const keys = productAutoExpandKeys.value return selectedExpandThroughLevel.value >= fullExpandThroughLevel.value
return keys.length > 0 && keys.every(key => expandedGroups.value[key] === true)
}) })
function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels) { function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels, tableKey = activeTab.value) {
appendGroupRowsAt(out, sourceRows, level, 0, parentKeys, levels) 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) { if (level >= levels.length) {
out.push(...sortProductLeafRows(sourceRows))
return return
} }
@@ -2614,16 +3037,17 @@ function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, lev
} }
Array.from(grouped.entries()) Array.from(grouped.entries())
.sort((a, b) => a[0].localeCompare(b[0], 'tr')) .sort((a, b) => compareGroupedBucketsForTable(tableKey, a, b, groupDef))
.forEach(([value, groupRows]) => { .forEach(([value, groupRows]) => {
if (out.length >= maxRenderedGroupedRows) return
if (shouldSkipGroupValue(groupDef.key, value)) { if (shouldSkipGroupValue(groupDef.key, value)) {
appendGroupRowsAt(out, groupRows, level + 1, visualLevel, parentKeys, levels) appendGroupRowsAt(out, groupRows, level + 1, visualLevel, parentKeys, levels, tableKey)
return return
} }
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|') const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
out.push(makeGroupRow(key, visualLevel, groupDef, value, groupRows)) out.push(makeGroupRow(key, visualLevel, groupDef, value, groupRows))
if (isGroupExpanded(key)) { 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)
} }
}) })
} }
@@ -2631,14 +3055,160 @@ function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, lev
function buildGroupedTableRows (tableKey, sourceRows) { function buildGroupedTableRows (tableKey, sourceRows) {
const levels = tabGroupLevels[tableKey] || groupLevels const levels = tabGroupLevels[tableKey] || groupLevels
const out = [] const out = []
appendGroupRows(out, sourceRows, 0, [`tab:${tableKey}`], levels) appendGroupRows(out, sourceRows, 0, [`tab:${tableKey}`], levels, tableKey)
return out return out
} }
function backendRowsForTab (tabKey, fallbackRows) { function backendRowsForTab (tabKey, fallbackRows) {
if (hasActiveTableFilters(tabKey)) return fallbackRows if (shouldUseBackendGroupedRows(tabKey)) {
const rows = backendGroupedRows.value[tabKey] const rows = backendGroupedRows.value[tabKey]
return Array.isArray(rows) && rows.length ? rows : fallbackRows 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) { function collectGroupKeys (out, sourceRows, level, parentKeys, levels = groupLevels, maxLevel = levels.length - 1) {
@@ -2679,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) { function sortProductGroupedTableRows (tableRows, sortBy, descending) {
const source = Array.isArray(tableRows) ? tableRows : [] const source = Array.isArray(tableRows) ? tableRows : []
if (backendGroupedSupportedTab(activeTab.value)) return source
if (!sortBy || !source.some(row => row?.__group)) { if (!sortBy || !source.some(row => row?.__group)) {
return sortFlatTableRows(source, sortBy, descending) return sortFlatTableRows(source, sortBy, descending)
} }
@@ -2773,9 +3390,9 @@ function sortValueForColumnName (row, columnName) {
function makeGroupRow (key, level, groupDef, value, groupRows) { function makeGroupRow (key, level, groupDef, value, groupRows) {
const imageSource = groupRows.find(row => row?.product_code) || {} const imageSource = groupRows.find(row => row?.product_code) || {}
const aggregate = aggregateGroupFields(groupRows, groupDef.key) const aggregate = aggregateGroupFields(groupRows, groupDef.key)
const colorSummary = groupDef.key === 'color_code' ? value : distinctSummary(groupRows, 'color_code') const colorSummary = groupDef.key === 'color_code' ? value : ''
const yakaSummary = groupDef.key === 'yaka_kodu' ? value : distinctSummary(groupRows, 'yaka_kodu') const yakaSummary = groupDef.key === 'yaka_kodu' ? value : ''
const colorDescriptionSummary = distinctSummary(groupRows, 'color_description') const colorDescriptionSummary = groupDef.key === 'color_code' ? distinctSummary(groupRows, 'color_description') : ''
return { return {
__group: true, __group: true,
row_key: `group|${key}`, row_key: `group|${key}`,
@@ -2789,21 +3406,21 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
image_color_code: imageSource.color_code || '', image_color_code: imageSource.color_code || '',
image_color_description: imageSource.color_description || '', image_color_description: imageSource.color_description || '',
image_yaka_kodu: imageSource.yaka_kodu || '', 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_code: colorSummary,
color_description: colorDescriptionSummary, color_description: colorDescriptionSummary,
yaka_kodu: yakaSummary, yaka_kodu: yakaSummary,
item_description: distinctSummary(groupRows, 'item_description'), item_description: groupDef.key === 'item_description' ? value : '',
kategori: groupDef.key === 'kategori' ? value : distinctSummary(groupRows, 'kategori'), kategori: groupDef.key === 'kategori' ? value : '',
urun_ilk_grubu: groupDef.key === 'urun_ilk_grubu' ? value : distinctSummary(groupRows, 'urun_ilk_grubu'), urun_ilk_grubu: groupDef.key === 'urun_ilk_grubu' ? value : '',
askili_yan: groupDef.key === 'askili_yan' ? value : distinctSummary(groupRows, 'askili_yan'), askili_yan: groupDef.key === 'askili_yan' ? value : '',
urun_ana_grubu: groupDef.key === 'urun_ana_grubu' ? value : distinctSummary(groupRows, 'urun_ana_grubu'), urun_ana_grubu: groupDef.key === 'urun_ana_grubu' ? value : '',
urun_alt_grubu: groupDef.key === 'urun_alt_grubu' ? value : distinctSummary(groupRows, 'urun_alt_grubu'), urun_alt_grubu: groupDef.key === 'urun_alt_grubu' ? value : '',
market_key: groupDef.key === 'market_key' ? value : distinctSummary(groupRows, 'market_key'), market_key: groupDef.key === 'market_key' ? value : '',
country: groupDef.key === 'country' ? value : distinctSummary(groupRows, 'country'), country: groupDef.key === 'country' ? value : '',
customer_segment: groupDef.key === 'customer_segment' ? value : distinctSummary(groupRows, 'customer_segment'), customer_segment: groupDef.key === 'customer_segment' ? value : '',
customer_code: groupDef.key === 'customer_code' ? value : distinctSummary(groupRows, 'customer_code'), customer_code: groupDef.key === 'customer_code' ? value : '',
customer_name: groupDef.key === 'customer_name' ? value : distinctSummary(groupRows, 'customer_name'), customer_name: groupDef.key === 'customer_name' ? value : '',
stock_qty: sumRows(groupRows, 'stock_qty'), stock_qty: sumRows(groupRows, 'stock_qty'),
sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'), sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'),
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'), sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
@@ -2816,16 +3433,16 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
gross_profit_usd_90d: sumRows(groupRows, 'gross_profit_usd_90d'), gross_profit_usd_90d: sumRows(groupRows, 'gross_profit_usd_90d'),
gross_profit_usd_180d: sumRows(groupRows, 'gross_profit_usd_180d'), gross_profit_usd_180d: sumRows(groupRows, 'gross_profit_usd_180d'),
gross_profit_usd_total: sumRows(groupRows, 'gross_profit_usd_total'), gross_profit_usd_total: sumRows(groupRows, 'gross_profit_usd_total'),
market_count_90d: distinctCount(groupRows, 'market_key'), market_count_90d: distinctSpreadCount(groupRows, 'market_count_90d'),
customer_count_90d: sumRows(groupRows, 'customer_count_90d'), customer_count_90d: distinctSpreadCount(groupRows, 'customer_count_90d'),
market_count_total: sumRows(groupRows, 'market_count_total'), market_count_total: distinctSpreadCount(groupRows, 'market_count_total'),
customer_count_total: sumRows(groupRows, 'customer_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_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'),
avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'), avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'),
avg_price_usd_365d: weightedAverage(groupRows, 'sales_usd_365d', 'sales_qty_365d'), avg_price_usd_365d: weightedAverage(groupRows, 'sales_usd_365d', 'sales_qty_365d'),
avg_price_usd_total: weightedAverage(groupRows, 'sales_usd_total', 'sales_qty_total'), avg_price_usd_total: weightedAverage(groupRows, 'sales_usd_total', 'sales_qty_total'),
base_price_usd: weightedAverageOrAverage(groupRows, 'base_price_usd', 'sales_qty_90d'), base_price_usd: weightedAverageProductCost(groupRows, 'base_price_usd'),
cost_price_usd: weightedAverageOrAverage(groupRows, 'cost_price_usd', 'sales_qty_90d'), cost_price_usd: weightedAverageProductCost(groupRows, 'cost_price_usd'),
unit_profit_base_90d: weightedAverageOrAverage(groupRows, 'unit_profit_base_90d', 'sales_qty_90d'), unit_profit_base_90d: weightedAverageOrAverage(groupRows, 'unit_profit_base_90d', 'sales_qty_90d'),
unit_profit_cost_90d: weightedAverageOrAverage(groupRows, 'unit_profit_cost_90d', 'sales_qty_90d'), unit_profit_cost_90d: weightedAverageOrAverage(groupRows, 'unit_profit_cost_90d', 'sales_qty_90d'),
unit_profit_base_180d: weightedAverageOrAverage(groupRows, 'unit_profit_base_180d', 'sales_qty_180d'), unit_profit_base_180d: weightedAverageOrAverage(groupRows, 'unit_profit_base_180d', 'sales_qty_180d'),
@@ -2847,8 +3464,10 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
sales_index_total: averageRows(groupRows, 'sales_index_total'), sales_index_total: averageRows(groupRows, 'sales_index_total'),
performance_bucket: dominantValue(groupRows, 'performance_bucket'), performance_bucket: dominantValue(groupRows, 'performance_bucket'),
...aggregate, ...aggregate,
performance_score: groupPerformanceScore(aggregate, groupDef.key), performance_score: Object.prototype.hasOwnProperty.call(aggregate, 'performance_score')
recommendation: `${formatNumber(groupRows.length, 0)} satır` ? aggregate.performance_score
: groupPerformanceScore(aggregate, groupDef.key),
recommendation: dominantValue(groupRows, 'recommendation') || `${formatNumber(groupRows.length, 0)} satır`
} }
} }
@@ -2861,14 +3480,23 @@ function aggregateGroupFields (sourceRows, groupField = '') {
for (const field of fields) { for (const field of fields) {
if (field === 'row_key' || field === 'key') continue if (field === 'row_key' || field === 'key') continue
if (field === 'stock_qty') { if (/^gross_margin(_base|_cost)?(_|$)/i.test(field)) continue
out[field] = distinctVariantStockQty(sourceRows) if (field === 'stock_qty' || /^avg_stock_/i.test(field)) {
out[field] = distinctVariantNumber(sourceRows, field)
continue continue
} }
if (field === 'idle_cost_usd') { if (field === 'idle_cost_usd') {
out[field] = distinctVariantStockCost(sourceRows) out[field] = distinctVariantStockCost(sourceRows)
continue continue
} }
if (field === 'base_price_usd' || field === 'cost_price_usd') {
out[field] = weightedAverageProductCost(sourceRows, field)
continue
}
if (isDistinctSpreadCountField(field)) {
out[field] = distinctSpreadCount(sourceRows, field)
continue
}
if (shouldSumField(field)) { if (shouldSumField(field)) {
out[field] = sumRows(sourceRows, field) out[field] = sumRows(sourceRows, field)
} else if (shouldAverageField(field)) { } else if (shouldAverageField(field)) {
@@ -2881,15 +3509,18 @@ function aggregateGroupFields (sourceRows, groupField = '') {
} }
function shouldSumField (field) { 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) { 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) /(_price_usd|_margin|_index|_days)$/i.test(field)
} }
function weightFieldForMetric (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('_180d')) return 'sales_qty_180d'
if (field.includes('_365d')) return 'sales_qty_365d' if (field.includes('_365d')) return 'sales_qty_365d'
if (field.includes('_total')) return 'sales_qty_total' if (field.includes('_total')) return 'sales_qty_total'
@@ -2899,21 +3530,65 @@ function weightFieldForMetric (field) {
} }
function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { 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']) { for (const suffix of ['90d', '180d', '365d', 'total']) {
const sales = Number(out[`sales_usd_${suffix}`] || 0) const sales = Number(out[`sales_usd_${suffix}`] || 0)
const qty = Number(out[`sales_qty_${suffix}`] || 0) const qty = Number(out[`sales_qty_${suffix}`] || 0)
if (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty const stockQty = Number(out.stock_qty || 0)
if (sales > 0) { const avgStock = Number(out[`avg_stock_${suffix}`] || 0)
if (out[`gross_profit_base_usd_${suffix}`] !== undefined) { const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty)
out[`gross_margin_base_${suffix}`] = Number(out[`gross_profit_base_usd_${suffix}`] || 0) / sales const days = productPerformancePeriodDays(out, suffix)
} const avgDaily = days > 0 ? qty / days : Number(out[`avg_daily_sales_${suffix}`] || 0)
if (out[`gross_profit_cost_usd_${suffix}`] !== undefined) { if (days > 0) out[`avg_daily_sales_${suffix}`] = avgDaily
out[`gross_margin_cost_${suffix}`] = Number(out[`gross_profit_cost_usd_${suffix}`] || 0) / sales if (avgDaily > 0 && turnoverBase > 0) out[`stock_days_${suffix}`] = turnoverBase / avgDaily
} else if (qty <= 0 && stockQty > 0) out[`stock_days_${suffix}`] = 9999
if (out[`gross_profit_usd_${suffix}`] !== undefined) { else if (!Object.prototype.hasOwnProperty.call(out, `stock_days_${suffix}`)) out[`stock_days_${suffix}`] = 0
out[`gross_margin_${suffix}`] = Number(out[`gross_profit_usd_${suffix}`] || 0) / sales 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) const orderUsd = Number(out.order_usd || 0)
@@ -2931,15 +3606,33 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') {
if (sourceRows.some(row => row.performance_bucket)) { if (sourceRows.some(row => row.performance_bucket)) {
out.performance_bucket = dominantValue(sourceRows, 'performance_bucket') out.performance_bucket = dominantValue(sourceRows, 'performance_bucket')
} }
out.customer_score_90d = customerSalesPeriodScore(periodMetricSource(out, '90d')) for (const suffix of ['90d', '180d', '365d', 'total']) {
out.customer_score_180d = customerSalesPeriodScore(periodMetricSource(out, '180d')) out[`customer_score_${suffix}`] = Object.prototype.hasOwnProperty.call(preservedCustomerScores, suffix)
out.customer_score_365d = customerSalesPeriodScore(periodMetricSource(out, '365d')) ? preservedCustomerScores[suffix]
out.customer_score_total = customerSalesPeriodScore(periodMetricSource(out, 'total')) : customerSalesPeriodScore(periodMetricSource(out, suffix))
out.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(out, '90d')) out[`performance_score_${suffix}`] = Object.prototype.hasOwnProperty.call(preservedProductScores, suffix)
out.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(out, '180d')) ? preservedProductScores[suffix]
out.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(out, '365d')) : productSalesPeriodScore(productPeriodMetricSource(out, suffix))
out.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(out, 'total')) }
out.performance_score = groupPerformanceScore(out, groupField) 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 = '') { function groupPerformanceScore (row, groupField = '') {
@@ -2953,33 +3646,39 @@ function salesGroupPerformanceScore (row) {
} }
function productSalesPeriodScore (row) { function productSalesPeriodScore (row) {
const salesIndex = Number(row?.sales_index_90d || 0) const suffix = row?.suffix || '90d'
const margin = Number(row?.gross_margin_cost_90d ?? 0) const periodDays = productPerformancePeriodDays(row, suffix)
const invoices = Number(row?.invoice_count_90d || 0) return productScore100({
const qty = Number(row?.sales_qty_90d || 0) suffix,
let stockTurnover = Number(row?.stock_turnover_90d || 0) periodDays,
const stockQty = Number(row?.stock_qty || 0) salesUSD: Number(row?.sales_usd_90d || 0),
if (!stockTurnover && stockQty > 0) stockTurnover = qty / stockQty salesIndex: Number(row?.sales_index_90d || 0),
return Math.min(35, Math.max(0, salesIndex) * 18) + margin: Number(row?.gross_margin_cost_90d ?? row?.gross_margin_90d ?? 0),
Math.min(30, Math.max(0, margin) * 60) + stockTurnover: Number(row?.stock_turnover_90d || 0),
Math.min(15, invoices * 1.5) + marketCount: Number(row?.market_count_90d || 0),
Math.min(10, qty / 10) + customerCount: Number(row?.customer_count_90d || 0)
Math.min(10, Math.max(0, stockTurnover) * 5) })
} }
function productPeriodMetricSource (row, suffix) { function productPeriodMetricSource (row, suffix) {
const salesIndex = Number(row?.[`sales_index_${suffix}`] || 0) const salesIndex = Number(row?.[`sales_index_${suffix}`] || 0)
const qty = Number(row?.[`sales_qty_${suffix}`] || 0) const qty = Number(row?.[`sales_qty_${suffix}`] || 0)
const stockQty = Number(row?.stock_qty || 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 turnoverKey = `stock_turnover_${suffix}`
const hasTurnover = Object.prototype.hasOwnProperty.call(row || {}, turnoverKey) const hasTurnover = Object.prototype.hasOwnProperty.call(row || {}, turnoverKey)
return { return {
suffix,
sales_index_90d: salesIndex, 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_cost_90d: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
gross_margin_90d: Number(row?.[`gross_margin_${suffix}`] ?? 0), gross_margin_90d: Number(row?.[`gross_margin_${suffix}`] ?? 0),
invoice_count_90d: Number(row?.[`invoice_count_${suffix}`] || 0),
sales_qty_90d: qty, 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 stock_qty: stockQty
} }
} }
@@ -2998,29 +3697,131 @@ function customerSalesGroupPerformanceScore (row) {
} }
function customerSalesPeriodScore (row) { function customerSalesPeriodScore (row) {
const salesUSD = Number(row?.sales_usd || 0) return customerScore100({
const margin = Number(row?.gross_margin_cost ?? 0) suffix: row?.suffix || '90d',
const invoices = Number(row?.invoice_count || 0) salesUSD: Number(row?.sales_usd || 0),
const qty = Number(row?.sales_qty || 0) margin: Number(row?.gross_margin_cost ?? row?.gross_margin ?? 0),
const productCount = Number(row?.product_count || 0) productGroupCount: Number(row?.product_group_count || row?.product_count || 0),
return Math.min(35, salesUSD / 1000) + salesQty: Number(row?.sales_qty || 0)
Math.min(25, Math.max(0, margin) * 55) + })
Math.min(20, invoices * 2) +
Math.min(10, qty / 10) +
Math.min(10, productCount)
} }
function periodMetricSource (row, suffix) { function periodMetricSource (row, suffix) {
return { return {
suffix,
sales_usd: Number(row?.[`sales_usd_${suffix}`] || 0), sales_usd: Number(row?.[`sales_usd_${suffix}`] || 0),
gross_margin_cost: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0), gross_margin_cost: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
gross_margin: Number(row?.[`gross_margin_${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), sales_qty: Number(row?.[`sales_qty_${suffix}`] || 0),
product_group_count: periodCount(row, 'product_group_count', suffix),
product_count: Number(row?.product_count || 0) 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) { function orderGroupPerformanceScore (row) {
const orderUSD = Number(row?.order_usd || 0) const orderUSD = Number(row?.order_usd || 0)
const margin = Number(row?.expected_margin_cost || 0) const margin = Number(row?.expected_margin_cost || 0)
@@ -3047,19 +3848,23 @@ function productVariantStockKey (row) {
} }
function distinctVariantStockQty (sourceRows) { function distinctVariantStockQty (sourceRows) {
return distinctVariantNumber(sourceRows, 'stock_qty')
}
function distinctVariantNumber (sourceRows, field) {
const seen = new Set() const seen = new Set()
let total = 0 let total = 0
let keyed = false let keyed = false
for (const row of sourceRows) { for (const row of sourceRows) {
const key = productVariantStockKey(row) const key = productVariantStockKey(row)
const stockQty = Number(row?.stock_qty || 0) const value = Number(row?.[field] || 0)
if (!key) continue if (!key) continue
keyed = true keyed = true
if (seen.has(key)) continue if (seen.has(key)) continue
seen.add(key) seen.add(key)
total += stockQty total += value
} }
return keyed ? total : sumRows(sourceRows, 'stock_qty') return keyed ? total : sumRows(sourceRows, field)
} }
function distinctVariantStockCost (sourceRows) { function distinctVariantStockCost (sourceRows) {
@@ -3088,6 +3893,83 @@ function distinctCount (sourceRows, field) {
return values.size 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) { function distinctSummary (sourceRows, field, fallbackField) {
const values = new Set() const values = new Set()
for (const row of sourceRows) { for (const row of sourceRows) {
@@ -3136,6 +4018,9 @@ function weightedAverage (sourceRows, amountField, qtyField) {
} }
function weightedAverageOrAverage (sourceRows, valueField, qtyField) { function weightedAverageOrAverage (sourceRows, valueField, qtyField) {
if (/^(performance_score|customer_score)/i.test(valueField)) {
return weightedAverageScore(sourceRows, valueField, scoreSuffixForMetric(valueField))
}
const weightedRows = sourceRows const weightedRows = sourceRows
.map(row => ({ .map(row => ({
value: Number(row[valueField] || 0), value: Number(row[valueField] || 0),
@@ -3149,6 +4034,60 @@ function weightedAverageOrAverage (sourceRows, valueField, qtyField) {
return 0 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 => ({
value: Number(row[valueField] || 0),
qty: Number(row.sales_qty_total || 0)
}))
.filter(row => Number.isFinite(row.value) && Number.isFinite(row.qty) && row.value > 0 && row.qty > 0)
const salesQty = salesRows.reduce((sum, row) => sum + row.qty, 0)
if (salesQty > 0) {
return salesRows.reduce((sum, row) => sum + row.value * row.qty, 0) / salesQty
}
const variants = new Map()
for (const row of sourceRows) {
const key = productVariantStockKey(row)
if (!key || variants.has(key)) continue
variants.set(key, row)
}
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) { function marginFromSalesCost (salesUSD, qty, unitCost) {
const sales = Number(salesUSD || 0) const sales = Number(salesUSD || 0)
if (!sales) return 0 if (!sales) return 0
@@ -3165,72 +4104,129 @@ function marginFromRows (sourceRows, salesField, qtyField, unitCostField) {
} }
function normalizeGroupValue (value) { function normalizeGroupValue (value) {
return String(value || '').trim() return String(value || '').trim() || '(Boş)'
} }
function shouldSkipGroupValue (field, value) { 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 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) { 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) { function groupKeyDepth (key) {
lastManualExpandedGroupKey.value = key const parts = String(key || '').split('|').filter(Boolean)
expandedGroups.value = { return Math.max(-1, parts.length - 2)
...expandedGroups.value,
[key]: !isGroupExpanded(key)
}
scheduleLoadBackendGroupedRows()
} }
function toggleAllProductGroups () { function tabKeyFromGroupKey (key) {
const keys = productAutoExpandKeys.value const first = String(key || '').split('|')[0] || ''
if (allProductGroupsExpanded.value) { return first.startsWith('tab:') ? first.slice(4) : activeTab.value
collapseAllProductGroups()
return
}
expandSelectedProductGroups()
} }
function collapseAllProductGroups () { function selectedExpandThroughLevelForTab (tabKey) {
expandedGroups.value = {} const levels = tabGroupLevels[tabKey] || groupLevels
lastManualExpandedGroupKey.value = '' const selected = new Set(selectedExpandLevelKeysByTab[tabKey] || [])
scheduleLoadBackendGroupedRows() 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 () { function activeTabPrefix () {
const keys = productAutoExpandKeys.value return `tab:${activeTab.value}|`
const expandableKeys = keys.slice(0, maxBulkExpandKeys) }
const tabPrefix = `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( const next = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith(tabPrefix)) 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 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 = '' 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() 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) { function groupLabelColumnName (row) {
const field = String(row?.group_field || '') const field = String(row?.group_field || '')
if (field === 'color_code' || field === 'yaka_kodu') return 'color_yaka' if (field === 'color_code' || field === 'yaka_kodu' || field === 'color_yaka') return 'color_yaka'
return field || 'product_code' return field || 'product_code'
} }
function groupDisplayLabel (row) { function groupDisplayLabel (row) {
const field = String(row?.group_field || '') const field = String(row?.group_field || '')
if (field === 'color_code' || field === 'yaka_kodu') { if (field === 'color_code' || field === 'yaka_kodu' || field === 'color_yaka') {
return colorYakaValue(row) || row?.label || row?.group_value || '' return colorYakaValue(row) || row?.label || row?.group_value || ''
} }
return row?.label || row?.group_value || '' return row?.label || row?.group_value || ''
@@ -3247,18 +4243,24 @@ function hasProductVariantImageKey (row) {
} }
function canShowProductImage (row) { function canShowProductImage (row) {
return activeTab.value === 'products' && hasProductVariantImageKey(row) return isProductKpiTab.value && hasProductVariantImageKey(row)
} }
function groupShowsImage (row) { function groupShowsImage (row) {
return activeTab.value === 'products' && return isProductKpiTab.value &&
row?.__group && row?.__group &&
['product_code', 'color_code', 'yaka_kodu'].includes(String(row.group_field || '')) && ['product_code', 'color_code', 'yaka_kodu', 'color_yaka'].includes(String(row.group_field || '')) &&
hasProductVariantImageKey(row) hasProductVariantImageKey(row)
} }
const groupDimensionFields = new Set(dimensionColumnNames) 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) { function groupCellClass (colOrName) {
const name = colOrName?.name || colOrName const name = colOrName?.name || colOrName
if (name === 'image') return 'product-image-cell' if (name === 'image') return 'product-image-cell'
@@ -3272,43 +4274,120 @@ function formatGroupCell (row, colOrName) {
const name = colOrName?.name || colOrName const name = colOrName?.name || colOrName
if (name === 'image') return '' if (name === 'image') return ''
if (name === 'recommendation') return row.recommendation || '' if (name === 'recommendation') return row.recommendation || ''
if (name === 'color_yaka' && ['color_code', 'yaka_kodu'].includes(String(row?.group_field || ''))) return colorYakaValue(row) if (name === 'color_yaka' && ['color_code', 'yaka_kodu', 'color_yaka'].includes(String(row?.group_field || ''))) return colorYakaValue(row)
if (groupDimensionFields.has(name) && name !== row?.group_field) return '' if (groupDimensionFields.has(name) && name !== row?.group_field) return ''
return formatTableCell(row, colOrName) return formatTableCell(row, colOrName)
} }
function withProductMargins (row) { function withProductMargins (row) {
return { const next = normalizeProductCostFields(row)
...row, const out = {
avg_price_usd_365d: Number(row?.sales_qty_365d || 0) > 0 ? Number(row?.sales_usd_365d || 0) / Number(row?.sales_qty_365d || 0) : 0, ...next,
stock_turnover_90d: existingOrStockTurnover(row?.stock_turnover_90d, row?.sales_qty_90d, row?.stock_qty), 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_180d: existingOrStockTurnover(row?.stock_turnover_180d, row?.sales_qty_180d, row?.stock_qty), stock_turnover_90d: groupedOrExistingStockTurnover(next, '90d', 90),
stock_turnover_365d: existingOrStockTurnover(row?.stock_turnover_365d, row?.sales_qty_365d, row?.stock_qty), stock_turnover_180d: groupedOrExistingStockTurnover(next, '180d', 180),
gross_margin_base_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.base_price_usd), stock_turnover_365d: groupedOrExistingStockTurnover(next, '365d', 360),
gross_margin_cost_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.cost_price_usd), stock_turnover_total: groupedOrExistingStockTurnover(next, 'total', productPerformancePeriodDays(next, 'total'))
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)
} }
for (const suffix of ['90d', '180d', '365d', 'total']) {
applyPeriodProfitFields(out, suffix)
}
return out
} }
function withGeneralMargins (row) { function withGeneralMargins (row) {
const out = normalizeProductCostFields(row)
applyPeriodProfitFields(out, 'total')
return { return {
...row, ...out,
stock_turnover_total: existingOrStockTurnover(row?.stock_turnover_total, row?.sales_qty_total, row?.stock_qty), stock_turnover_total: groupedOrExistingStockTurnover(out, 'total', productPerformancePeriodDays(out, 'total'))
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)
} }
} }
function stockTurnover (salesQty, stockQty) { function normalizeProductCostFields (row) {
const stock = Number(stockQty || 0) const next = { ...row }
if (stock <= 0) return 0 normalizeProductCostPairFields(next, 'cost_price_usd', 'base_price_usd')
return Number(salesQty || 0) / stock 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) 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) { function filterKey (tableKey, name) {
@@ -3320,6 +4399,7 @@ function isColumnFilterable (name) {
} }
function columnFilterOptions (tableKey, name) { function columnFilterOptions (tableKey, name) {
if (backendGroupedSupportedTab(tableKey) && !backendGroupedFilterFields.has(name)) return []
const options = columnFilterOptionMap.value[tableKey]?.[name] || [] const options = columnFilterOptionMap.value[tableKey]?.[name] || []
const search = String(columnFilterSearch[filterKey(tableKey, name)] || '').trim().toLocaleLowerCase('tr') const search = String(columnFilterSearch[filterKey(tableKey, name)] || '').trim().toLocaleLowerCase('tr')
if (!search) return options if (!search) return options
@@ -3371,36 +4451,26 @@ function selectAllColumnFilterOptions (tableKey, name) {
} }
function runWithFilterBusy (apply) { function runWithFilterBusy (apply) {
filterBusy.value = true apply()
const done = () => { syncExpansionAfterFilterChange()
filterBusy.value = false }
}
const execute = () => { function syncExpansionAfterFilterChange () {
apply() if (!backendGroupedSupportedTab(activeTab.value)) return
if (typeof window === 'undefined') { const level = selectedExpandThroughLevel.value
setTimeout(done, 0) applyActiveExpansionThroughLevel(level >= 0 ? autoExpandThroughLevel.value : -1)
return
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(done)
})
}
if (typeof window === 'undefined') {
execute()
return
}
window.requestAnimationFrame(execute)
} }
function filterRowsForTable (tableKey, sourceRows, sourceColumns) { function filterRowsForTable (tableKey, sourceRows, sourceColumns) {
return sourceRows.filter(row => { const activeFilters = sourceColumns
return sourceColumns.every(col => { .filter(col => isColumnFilterable(col.name))
if (!isColumnFilterable(col.name)) return true .map(col => ({
const selected = selectedColumnFilters(tableKey, col.name) col,
if (!selected.length) return true selected: new Set(selectedColumnFilters(tableKey, col.name))
return selected.includes(columnFilterValue(row, col)) }))
}) .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) { function columnFilterValue (row, col) {
@@ -3418,6 +4488,12 @@ function colorYakaValue (row) {
const colorDescription = String(row?.image_color_description || row?.color_description || '').trim() const colorDescription = String(row?.image_color_description || row?.color_description || '').trim()
const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim() const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
const colorLabel = colorDisplayLabel(groupField === 'color_code' ? groupValue || color : color, colorDescription) 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 === 'color_code') return colorLabel
if (row?.__group && groupField === 'yaka_kodu') { if (row?.__group && groupField === 'yaka_kodu') {
const parentColor = colorLabel && colorLabel !== '-' ? colorLabel : '' const parentColor = colorLabel && colorLabel !== '-' ? colorLabel : ''
@@ -3456,7 +4532,13 @@ function productFirstGroupValue (row) {
} }
function productMainGroupValue (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) { function cleanOptionalGroupValue (value) {
@@ -3555,7 +4637,7 @@ function parseSortNumber (value) {
if (!text) return NaN if (!text) return NaN
const normalized = text const normalized = text
.replace(/\s/g, '') .replace(/\s/g, '')
.replace(/[%$₺€£]/g, '') .replace(/[%$\u20ba\u20ac\u00a3]/g, '')
.replace(/\.(?=\d{3}(\D|$))/g, '') .replace(/\.(?=\d{3}(\D|$))/g, '')
.replace(/,/g, '.') .replace(/,/g, '.')
.replace(/[^\d.-]/g, '') .replace(/[^\d.-]/g, '')
@@ -3579,7 +4661,7 @@ function formatTableCell (row, colOrName) {
if (isMoneyField(name)) return formatMoney(row[name], 'USD') if (isMoneyField(name)) return formatMoney(row[name], 'USD')
if (isPercentField(name)) return formatPercent(row[name]) if (isPercentField(name)) return formatPercent(row[name])
if (isIntegerField(name)) return formatNumber(row[name], 0) if (isIntegerField(name)) return formatNumber(row[name], 0)
if (isDecimalField(name)) return formatNumber(row[name], name === 'performance_score' ? 1 : 2) if (isDecimalField(name)) return formatNumber(row[name], 2)
if (typeof col?.field === 'function') return col.field(row) if (typeof col?.field === 'function') return col.field(row)
return formatProductCell(row, name) return formatProductCell(row, name)
} }
@@ -3623,7 +4705,7 @@ function formatProductCell (row, name) {
case 'sales_index_90d': case 'sales_index_90d':
case 'sales_index_total': case 'sales_index_total':
case 'performance_score': case 'performance_score':
return formatNumber(row[name], name === 'performance_score' ? 1 : 2) return formatNumber(row[name], 2)
case 'sales_usd_90d': case 'sales_usd_90d':
case 'sales_usd_180d': case 'sales_usd_180d':
case 'sales_usd_365d': case 'sales_usd_365d':
@@ -3643,11 +4725,21 @@ function formatProductCell (row, name) {
case 'gross_profit_usd_90d': case 'gross_profit_usd_90d':
case 'gross_profit_usd_180d': case 'gross_profit_usd_180d':
case 'gross_profit_usd_total': 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') return formatMoney(row[name], 'USD')
case 'gross_margin_base_90d': case 'gross_margin_base_90d':
case 'gross_margin_cost_90d': case 'gross_margin_cost_90d':
case 'gross_margin_base_180d': case 'gross_margin_base_180d':
case 'gross_margin_cost_180d': case 'gross_margin_cost_180d':
case 'gross_margin_base_365d':
case 'gross_margin_cost_365d':
case 'gross_margin_base_total': case 'gross_margin_base_total':
case 'gross_margin_cost_total': case 'gross_margin_cost_total':
case 'gross_margin_90d': case 'gross_margin_90d':
@@ -3671,19 +4763,19 @@ function normalizeRow (row) {
const color = String(row?.color_code || '').trim() const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim() const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim() const market = String(row?.market_key || '').trim()
return withPeriodScores(withProductMargins({ return withPeriodScores(withGeneralMargins(withProductMargins({
...row, ...row,
row_key: `${product}|${color}|${yaka}|${market}` row_key: `${product}|${color}|${yaka}|${market}`
})) })))
} }
function withPeriodScores (row) { function withPeriodScores (row) {
const next = { ...row } const next = { ...row }
next.performance_score_90d = Number(next.performance_score_90d || productSalesPeriodScore(productPeriodMetricSource(next, '90d'))) if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_90d')) next.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(next, '90d'))
next.performance_score_180d = Number(next.performance_score_180d || productSalesPeriodScore(productPeriodMetricSource(next, '180d'))) if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_180d')) next.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(next, '180d'))
next.performance_score_365d = Number(next.performance_score_365d || productSalesPeriodScore(productPeriodMetricSource(next, '365d'))) if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_365d')) next.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(next, '365d'))
next.performance_score_total = Number(next.performance_score_total || productSalesPeriodScore(productPeriodMetricSource(next, 'total'))) if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_total')) 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')) next.performance_score = next.performance_score_90d
return next return next
} }
@@ -3804,47 +4896,47 @@ function productImageKey (row) {
return `${product}|${color}|${yaka}` 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) { function displayMarketName (value) {
const parts = String(value || '').split('|').map(part => part.trim()).filter(Boolean) const parts = String(value || '').split('|').map(part => part.trim()).filter(Boolean)
return parts.length ? parts[parts.length - 1] : '' return parts.length ? parts[parts.length - 1] : ''
} }
function normalizeUploadsPath (storagePath) { function resolveProductImageUrl (item) {
const raw = String(storagePath || '').trim() return resolveProductImageUrls(item)[0] || ''
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) { function resolveProductImageUrls (item) {
if (!item || typeof item !== 'object') return '' if (!item || typeof item !== 'object') return []
const urls = []
const contentURL = String(item.content_url || item.ContentURL || '').trim() const addURL = value => {
if (contentURL.startsWith('/api/')) return contentURL const url = String(value || '').trim()
if (contentURL.startsWith('/')) return `/api${contentURL}` if (url && !urls.includes(url)) urls.push(url)
}
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim() const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
if (thumbURL) return thumbURL addURL(thumbURL)
const fullURL = String(item.full_url || item.fullUrl || '').trim() const fullURL = String(item.full_url || item.fullUrl || '').trim()
if (fullURL) return fullURL addURL(fullURL)
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '') const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (uploadsPath) return uploadsPath if (contentURL.startsWith('/api/')) addURL(contentURL)
else if (contentURL.startsWith('/')) addURL(`/api${contentURL}`)
const fileName = String(item.file_name || item.FileName || '').trim() return urls
return fileName ? `/uploads/image/${fileName}` : ''
} }
async function fetchProductImagesForRow (row) { async function fetchProductImagesForRow (row) {
if (!hasProductVariantImageKey(row)) return [] if (!hasProductVariantImageKey(row)) return []
const key = productImageKey(row) const key = productImageKey(row)
if (imageFailedKeys.value.has(key)) return []
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) { if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) {
return imageListByKey.value[key] return imageListByKey.value[key]
} }
@@ -3871,8 +4963,13 @@ async function fetchProductImagesForRow (row) {
timeout: 60000 timeout: 60000
}) })
const list = Array.isArray(resp?.data) ? resp.data : [] 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) 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 } imageListByKey.value = { ...imageListByKey.value, [key]: urls }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' } imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
return urls return urls
@@ -3887,19 +4984,64 @@ async function fetchProductImagesForRow (row) {
function getCachedProductImageUrl (row) { function getCachedProductImageUrl (row) {
if (!canShowProductImage(row)) return '' if (!canShowProductImage(row)) return ''
const key = productImageKey(row) 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) const key = productImageKey(row)
if (!key) return 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, []) performanceStore.setImageCache(key, [])
imageListByKey.value = { ...imageListByKey.value, [key]: [] } imageListByKey.value = { ...imageListByKey.value, [key]: [] }
imageUrlByKey.value = { ...imageUrlByKey.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) { async function primeProductImages (sourceRows) {
if (activeTab.value !== 'products') return if (!isProductKpiTab.value) return
imagePrimeActiveRequests += 1 imagePrimeActiveRequests += 1
imagePrimeLoading.value = true imagePrimeLoading.value = true
const items = [] const items = []
@@ -3909,7 +5051,7 @@ async function primeProductImages (sourceRows) {
if (!canShowProductImage(row)) continue if (!canShowProductImage(row)) continue
const key = productImageKey(row) const key = productImageKey(row)
const code = String(row?.image_product_code || row?.product_code || '').trim() 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) seen.add(key)
items.push({ items.push({
key, key,
@@ -3926,8 +5068,9 @@ async function primeProductImages (sourceRows) {
const nextUrls = { ...imageUrlByKey.value } const nextUrls = { ...imageUrlByKey.value }
for (const item of items) { for (const item of items) {
const urls = batch?.lists?.[item.key] || performanceStore.imageList(item.key) || [] 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 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 imageListByKey.value = nextLists
imageUrlByKey.value = nextUrls imageUrlByKey.value = nextUrls
@@ -4000,6 +5143,7 @@ async function loadProductDetailRows (row) {
function backendGroupedSupportedTab (tabKey = activeTab.value) { function backendGroupedSupportedTab (tabKey = activeTab.value) {
return tabKey === 'products' || return tabKey === 'products' ||
tabKey === 'product_detail' ||
tabKey === 'idle' || tabKey === 'idle' ||
tabKey === 'order_product_customers' || tabKey === 'order_product_customers' ||
tabKey === 'order_market_details' || tabKey === 'order_market_details' ||
@@ -4009,16 +5153,22 @@ function backendGroupedSupportedTab (tabKey = activeTab.value) {
function activeExpandedGroupKeys () { function activeExpandedGroupKeys () {
const tabPrefix = `tab:${activeTab.value}|` const tabPrefix = `tab:${activeTab.value}|`
const validFields = new Set(activeGroupLevels.value.map(level => level.key)) const validFields = new Set(activeGroupLevels.value.map(level => level.key))
const maxKeys = maxBackendExpandedKeysForTab(activeTab.value)
const keys = Object.entries(expandedGroups.value) const keys = Object.entries(expandedGroups.value)
.filter(([, value]) => value === true) .filter(([, value]) => value === true)
.map(([key]) => key) .map(([key]) => key)
.filter(key => key.startsWith(tabPrefix)) .filter(key => key.startsWith(tabPrefix))
.filter(key => key.split('|').slice(1).every(part => validFields.has(String(part).split(':')[0]))) .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 manualKey = lastManualExpandedGroupKey.value
const out = [] const out = []
if (manualKey && keys.includes(manualKey)) out.push(manualKey) if (manualKey && keys.includes(manualKey)) out.push(manualKey)
for (const key of keys) { for (const key of keys) {
if (out.length >= maxBulkExpandKeys) break if (out.length >= maxKeys) break
if (key !== manualKey) out.push(key) if (key !== manualKey) out.push(key)
} }
return out return out
@@ -4041,29 +5191,114 @@ function scheduleLoadBackendGroupedRows () {
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer) if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
backendGroupedTimer = window.setTimeout(() => { backendGroupedTimer = window.setTimeout(() => {
void loadBackendGroupedRows() 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) { function switchPerformanceTab (tabName) {
if (!tabName || tabName === activeTab.value || pageBusy.value) return if (!tabName || tabName === activeTab.value || loading.value) return
backendGroupedLoading.value = true
activeTab.value = tabName activeTab.value = tabName
} }
async function loadBackendGroupedRows () { async function loadBackendGroupedRows (options = {}) {
const tabKey = activeTab.value const tabKey = activeTab.value
if (!backendGroupedSupportedTab(tabKey)) return 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() pruneActiveExpandedGroups()
backendGroupedLoading.value = true 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 = { const params = {
mode: tabKey, mode: tabKey,
groupLevels: activeGroupLevels.value.map(level => level.key), groupLevels: activeGroupLevels.value.map(level => level.key),
expandedKeys: activeExpandedGroupKeys(), expandedKeys: activeExpandedGroupKeys(),
limit: tabKey === 'products' || 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 { try {
const startedAt = performance.now() 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)) : [] const normalizedRows = Array.isArray(rows) ? rows.map(row => normalizeGroupedDisplayRow(tabKey, row)) : []
backendGroupedRows.value = { backendGroupedRows.value = {
...backendGroupedRows.value, ...backendGroupedRows.value,
@@ -4084,17 +5319,7 @@ async function loadBackendGroupedRows () {
function normalizeGroupedDisplayRow (tabKey, row) { function normalizeGroupedDisplayRow (tabKey, row) {
const nextRow = { ...row } const nextRow = { ...row }
normalizeGroupedProductHierarchyValues(nextRow) normalizeGroupedProductHierarchyValues(nextRow)
if (tabKey === 'order_product_customers' || tabKey === 'order_market_details') { return withPeriodScores(withGeneralMargins(withProductMargins(nextRow)))
return withOrderPeriodScores(nextRow)
}
const next = withPeriodScores(nextRow)
if (tabKey !== 'products' && 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
} }
function normalizeGroupedProductHierarchyValues (row) { function normalizeGroupedProductHierarchyValues (row) {
@@ -4140,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 () { async function reload () {
loading.value = true loading.value = true
startLoadingTimer('Rapor verileri hazırlanıyor...')
try { try {
const salesBreakdownRequests = salesBreakdownTabKeys.map(tabKey => { rows.value = []
return timedProductPerformanceGet(`sales-breakdown:${tabKey}`, '/pricing/product-performance/sales-breakdown', { detailMainGroupOptionRows.value = []
params: { limit: reportFetchLimit, mode: salesBreakdownModes[tabKey] }, generalRowsLoaded.value = false
timeout: 60000 generalRows.value = []
}) orderProductCustomersLoaded.value = false
}) orderProductCustomerRows.value = []
const [summaryResp, listResp, generalResp, marketResp, countryResp, customerResp, ...salesBreakdownResp] = await Promise.all([ orderMarketDetailsLoaded.value = false
timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 60000 }), orderMarketDetailRows.value = []
timedProductPerformanceGet('product-kpi', '/pricing/product-performance', { orderAnalysisLoaded.value = false
params: { orderAnalysisRows.value = []
page: 1, orderMarketRows.value = []
limit: productKpiFetchLimit orderCustomerRows.value = []
}, marketRowsLoaded.value = false
timeout: 60000 marketRows.value = []
}), countryRowsLoaded.value = false
timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: reportFetchLimit }, timeout: 60000 }), countryRows.value = []
timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: reportFetchLimit }, timeout: 60000 }), customerRowsLoaded.value = false
timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: reportFetchLimit }, timeout: 60000 }), customerRows.value = []
timedProductPerformanceGet('customers', '/pricing/product-performance/customers', { for (const key of salesBreakdownTabKeys) salesBreakdownRows[key] = []
params: { limit: reportFetchLimit, breakdown: customerBreakdown.value },
timeout: 60000 loadingStage.value = 'Ana KPI ve özet yükleniyor...'
}), const [summaryResp] = await Promise.all([
...salesBreakdownRequests timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 180000 }),
loadDetailMainGroupOptions(true)
]) ])
summary.value = summaryResp?.data || {} summary.value = summaryResp?.data || {}
const normalizedGeneralRows = (Array.isArray(generalResp?.data) ? generalResp.data : []).map(normalizeGeneralRow) loadingStage.value = 'Rapor ekranı hazırlanıyor...'
const generalByVariant = new Map(normalizedGeneralRows.map(row => [performanceVariantKey(row), generalMetricFields(row)])) await nextTick()
rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(row => { ensureDetailMainGroupSelection()
const generalMetrics = generalByVariant.get(performanceVariantKey(row)) || {} applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
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()
} catch (err) { } catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' }) Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
} finally { } finally {
loading.value = false 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) { async function loadOrderProductCustomers (showLoading = true) {
if (orderProductCustomersLoaded.value) return
if (showLoading) loading.value = true if (showLoading) loading.value = true
try { 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) orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
orderProductCustomersLoaded.value = true orderProductCustomersLoaded.value = true
} catch (err) { } catch (err) {
@@ -4215,9 +5595,10 @@ async function loadOrderProductCustomers (showLoading = true) {
} }
async function loadOrderMarketDetails (showLoading = true) { async function loadOrderMarketDetails (showLoading = true) {
if (orderMarketDetailsLoaded.value) return
if (showLoading) loading.value = true if (showLoading) loading.value = true
try { 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) orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow)
orderMarketDetailsLoaded.value = true orderMarketDetailsLoaded.value = true
} catch (err) { } catch (err) {
@@ -4227,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) { async function loadOrderAnalysis (showLoading = true) {
if (showLoading) loading.value = true if (showLoading) loading.value = true
try { try {
@@ -4262,29 +5759,53 @@ function bucketColor (value) {
} }
} }
watch(activeTab, tab => { watch(activeTab, () => {
if (tab === 'order_product_customers' && !orderProductCustomersLoaded.value) { void ensureActiveTabData(false).then(() => {
void loadOrderProductCustomers(true) if (backendGroupedSupportedTab(activeTab.value)) applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
} })
if (tab === 'order_market_details' && !orderMarketDetailsLoaded.value) { setupTopScrollbarSync()
void loadOrderMarketDetails(true) })
}
watch(activeBackendGroupedSortSignature, () => {
if (!backendGroupedSupportedTab(activeTab.value) || loading.value) return
scheduleLoadBackendGroupedRows() scheduleLoadBackendGroupedRows()
}) })
watch(activeSelectedExpandLevelKeys, () => { watch(activeDisplayedTableRows, () => {
scheduleLoadBackendGroupedRows() setupTopScrollbarSync()
}, { flush: 'post' })
watch(detailMainGroupOptions, () => {
ensureDetailMainGroupSelection()
}, { immediate: true })
watch(selectedDetailMainGroup, () => {
if (activeTab.value === 'product_detail') {
void nextTick(() => {
const level = selectedExpandThroughLevel.value
applyActiveExpansionThroughLevel(level >= 0 ? autoExpandThroughLevel.value : -1)
})
}
}) })
watch(productTableRows, tableRows => { watch(activeDisplayedTableRows, tableRows => {
if (activeTab.value !== 'products') return if (!isProductKpiTab.value) return
const imageRows = tableRows const imageRows = tableRows
.filter(row => !row.__group || groupShowsImage(row)) .filter(row => !row.__group || groupShowsImage(row))
.slice(0, 240) .slice(0, 240)
void primeProductImages(imageRows) void primeProductImages(imageRows)
}, { immediate: true }) }, { immediate: true })
onMounted(reload) onMounted(() => {
void reload()
setupTopScrollbarSync()
})
onBeforeUnmount(() => {
cleanupTopScrollbarSync()
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
stopLoadingTimer()
})
</script> </script>
<style scoped> <style scoped>
@@ -4302,7 +5823,7 @@ onMounted(reload)
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12px; gap: 12px;
background: rgba(246, 247, 249, 0.82); background: #f6f7f9;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
pointer-events: all; pointer-events: all;
} }
@@ -4313,6 +5834,12 @@ onMounted(reload)
font-weight: 700; font-weight: 700;
} }
.page-busy-time {
color: #5f6b7a;
font-size: 13px;
font-weight: 600;
}
.performance-top-bar { .performance-top-bar {
min-height: 34px; min-height: 34px;
} }
@@ -4411,6 +5938,49 @@ onMounted(reload)
opacity: 0.62; opacity: 0.62;
} }
.main-group-bar {
display: flex;
gap: 6px;
overflow-x: auto;
padding: 6px;
border: 1px solid #d7dde5;
border-radius: 6px;
background: #ffffff;
}
.main-group-button {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 4px 10px;
border: 1px solid #c9d2dd;
border-radius: 6px;
background: #f7f9fb;
color: #26384c;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.main-group-button small {
color: #64748b;
font-size: 10px;
font-weight: 600;
}
.main-group-button.active {
border-color: #1d4ed8;
background: #dbeafe;
color: #173a75;
}
.main-group-button:disabled {
cursor: wait;
opacity: 0.62;
}
.metric-card { .metric-card {
min-height: 38px; min-height: 38px;
} }
@@ -4441,8 +6011,31 @@ onMounted(reload)
font-size: 11px; font-size: 11px;
} }
.performance-top-scrollbar {
display: none;
height: 14px;
overflow-x: auto;
overflow-y: hidden;
background: #fff;
border: 1px solid #d5dce5;
border-radius: 4px;
}
.performance-top-scrollbar-inner {
height: 1px;
}
.performance-table :deep(.q-table__middle) { .performance-table :deep(.q-table__middle) {
max-height: calc(100vh - 220px); max-height: calc(100vh - 220px);
overflow: auto;
}
.performance-table :deep(.q-table__middle::-webkit-scrollbar:horizontal) {
height: 0;
}
.performance-table :deep(.q-table__bottom) {
display: none;
} }
.performance-table :deep(.q-table) { .performance-table :deep(.q-table) {
@@ -4520,7 +6113,7 @@ onMounted(reload)
.performance-table :deep(.q-table tbody td .q-field), .performance-table :deep(.q-table tbody td .q-field),
.performance-table :deep(.q-table tbody td input), .performance-table :deep(.q-table tbody td input),
.performance-table :deep(.q-table tbody td textarea) { .performance-table :deep(.q-table tbody td textarea) {
min-width: 54px; min-width: 72px;
max-width: 100%; max-width: 100%;
} }
@@ -4534,10 +6127,10 @@ onMounted(reload)
.performance-table :deep(.q-table th.color-yaka-cell), .performance-table :deep(.q-table th.color-yaka-cell),
.performance-table :deep(.q-table td.color-yaka-cell) { .performance-table :deep(.q-table td.color-yaka-cell) {
width: 132px; width: 170px;
min-width: 132px; min-width: 170px;
max-width: 150px; max-width: 190px;
font-size: 10px; font-size: 10.5px;
} }
.performance-table :deep(.q-table th:first-child), .performance-table :deep(.q-table th:first-child),
@@ -4545,115 +6138,261 @@ onMounted(reload)
border-left: 1px solid #d5dce5; 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; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 20;
background: #f8fbff; background: #f8fbff;
}
.performance-table :deep(th) {
border-top: 1px solid #d5dce5; border-top: 1px solid #d5dce5;
} }
.product-breakdown-table :deep(.q-table) { .product-breakdown-table :deep(.q-table) {
min-width: 3000px; min-width: 3170px;
table-layout: fixed; table-layout: fixed;
} }
.product-breakdown-table :deep(.q-table th: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 :deep(.q-table td: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; position: sticky;
z-index: 2; z-index: 2;
} }
.product-breakdown-table :deep(.q-table th:nth-child(-n+10)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)) {
z-index: 4; z-index: 30;
background: #f8fbff; 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; background: #fff;
} }
.product-breakdown-table :deep(.q-table th:nth-child(1)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :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 td:nth-child(1)) {
left: 0; left: 0;
width: 72px; width: 190px;
min-width: 72px; min-width: 190px;
max-width: 72px; max-width: 190px;
} }
.product-breakdown-table :deep(.q-table th:nth-child(2)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(2)),
.product-breakdown-table :deep(.q-table td:nth-child(2)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(2)) {
left: 72px; left: 190px;
width: 140px; width: 140px;
min-width: 140px; min-width: 140px;
max-width: 140px; max-width: 140px;
} }
.product-breakdown-table :deep(.q-table th:nth-child(3)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(3)),
.product-breakdown-table :deep(.q-table td:nth-child(3)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(3)) {
left: 212px; left: 330px;
width: 110px; width: 110px;
min-width: 110px; min-width: 110px;
max-width: 110px; max-width: 110px;
} }
.product-breakdown-table :deep(.q-table th:nth-child(4)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(4)),
.product-breakdown-table :deep(.q-table td:nth-child(4)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(4)) {
left: 322px; left: 440px;
width: 150px; width: 150px;
min-width: 150px; min-width: 150px;
max-width: 150px; max-width: 150px;
} }
.product-breakdown-table :deep(.q-table th:nth-child(5)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(5)),
.product-breakdown-table :deep(.q-table td:nth-child(5)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(5)) {
left: 472px; left: 590px;
width: 170px; width: 170px;
min-width: 170px; min-width: 170px;
max-width: 170px; max-width: 170px;
} }
.product-breakdown-table :deep(.q-table th:nth-child(6)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(6)),
.product-breakdown-table :deep(.q-table td:nth-child(6)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(6)) {
left: 642px; left: 760px;
width: 150px; width: 150px;
min-width: 150px; min-width: 150px;
max-width: 150px; max-width: 150px;
} }
.product-breakdown-table :deep(.q-table th:nth-child(7)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(7)),
.product-breakdown-table :deep(.q-table td:nth-child(7)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(7)) {
left: 792px; left: 910px;
width: 130px; width: 130px;
min-width: 130px; min-width: 130px;
max-width: 130px; max-width: 130px;
} }
.product-breakdown-table :deep(.q-table th:nth-child(8)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(8)),
.product-breakdown-table :deep(.q-table td:nth-child(8)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(8)) {
left: 922px; left: 1040px;
width: 90px; width: 90px;
min-width: 90px; min-width: 90px;
max-width: 90px; max-width: 90px;
} }
.product-breakdown-table :deep(.q-table th:nth-child(9)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(9)),
.product-breakdown-table :deep(.q-table td:nth-child(9)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(9)) {
left: 1012px; left: 1130px;
width: 80px; width: 80px;
min-width: 80px; min-width: 80px;
max-width: 80px; max-width: 80px;
} }
.product-breakdown-table :deep(.q-table th:nth-child(10)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(10)),
.product-breakdown-table :deep(.q-table td:nth-child(10)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(10)) {
left: 1092px; left: 1210px;
width: 130px; width: 130px;
min-width: 130px; min-width: 130px;
max-width: 130px; max-width: 130px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); 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: 2620px;
}
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(n+8)) {
position: static;
left: auto;
z-index: auto;
box-shadow: none;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(n+8)) {
left: auto;
z-index: 20;
box-shadow: none;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(-n+7)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(-n+7)) {
position: sticky;
z-index: 2;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(-n+7)) {
z-index: 30;
background: #f8fbff;
}
.product-breakdown-table.product-detail-table :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+7)) {
background: #fff;
}
.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: 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: 190px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.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: 340px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.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: 490px;
width: 130px;
min-width: 130px;
max-width: 130px;
}
.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: 620px;
width: 170px;
min-width: 170px;
max-width: 170px;
}
.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: 790px;
width: 170px;
min-width: 170px;
max-width: 190px;
}
.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: 960px;
width: 120px;
min-width: 120px;
max-width: 120px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.sticky-dim-table :deep(.q-table) { .sticky-dim-table :deep(.q-table) {
min-width: 1700px; min-width: 1700px;
table-layout: fixed; table-layout: fixed;
@@ -4670,16 +6409,25 @@ onMounted(reload)
.sticky-dim-table :deep(.q-table td.text-right), .sticky-dim-table :deep(.q-table td.text-right),
.product-breakdown-table :deep(.q-table th.text-right), .product-breakdown-table :deep(.q-table th.text-right),
.product-breakdown-table :deep(.q-table td.text-right) { .product-breakdown-table :deep(.q-table td.text-right) {
min-width: 98px; min-width: 124px;
width: 98px; width: 124px;
max-width: none; max-width: none;
white-space: nowrap; white-space: nowrap;
} }
.product-breakdown-table :deep(.q-table th:nth-child(n+11):not(.text-right)), .product-breakdown-table :deep(.q-table td.text-right) {
.product-breakdown-table :deep(.q-table td:nth-child(n+11):not(.text-right)), font-size: 10.5px;
font-variant-numeric: tabular-nums;
}
.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 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-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 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-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)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(n+7):not(.text-right)),
@@ -4759,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 th:nth-child(-n+3)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table td: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 th:nth-child(-n+5)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td: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)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)),
@@ -4778,6 +6528,7 @@ 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 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-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-6 :deep(.q-table th:nth-child(-n+6)),
.sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)), .sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)),
@@ -4785,7 +6536,7 @@ onMounted(reload)
.sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(-n+9)), .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-10 :deep(.q-table th:nth-child(-n+10)),
.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(-n+11)) { .sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(-n+11)) {
z-index: 4; z-index: 30;
background: #f8fbff; background: #f8fbff;
} }
@@ -4795,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 th:nth-child(3)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table td: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 th:nth-child(5)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td: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)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(6)),
@@ -4812,6 +6565,63 @@ onMounted(reload)
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
} }
.sales-color-yaka-table :deep(.q-table th:nth-child(1)),
.sales-color-yaka-table :deep(.q-table td:nth-child(1)) {
left: 0;
width: 170px;
min-width: 170px;
max-width: 190px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(2)),
.sales-color-yaka-table :deep(.q-table td:nth-child(2)) {
left: 170px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(3)),
.sales-color-yaka-table :deep(.q-table td:nth-child(3)) {
left: 288px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(4)),
.sales-color-yaka-table :deep(.q-table td:nth-child(4)) {
left: 406px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(5)),
.sales-color-yaka-table :deep(.q-table td:nth-child(5)) {
left: 524px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(6)),
.sales-color-yaka-table :deep(.q-table td:nth-child(6)) {
left: 642px;
width: 130px;
min-width: 130px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(7)),
.sales-color-yaka-table :deep(.q-table td:nth-child(7)) {
left: 772px;
width: 170px;
min-width: 170px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(8)),
.sales-color-yaka-table :deep(.q-table td:nth-child(8)) {
left: 942px;
width: 116px;
min-width: 116px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(9)),
.sales-color-yaka-table :deep(.q-table td:nth-child(9)) {
left: 1058px;
width: 190px;
min-width: 190px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.filterable-header-cell { .filterable-header-cell {
vertical-align: middle; vertical-align: middle;
} }
@@ -4924,6 +6734,28 @@ onMounted(reload)
border-color: #c4c9d1; 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 td span),
.performance-table :deep(.q-table tbody tr.group-row .group-title), .performance-table :deep(.q-table tbody tr.group-row .group-title),
.performance-table :deep(.q-table tbody tr.group-row .q-btn), .performance-table :deep(.q-table tbody tr.group-row .q-btn),
@@ -4959,32 +6791,38 @@ onMounted(reload)
} }
.product-image-cell { .product-image-cell {
width: 64px; width: 190px;
min-width: 64px; min-width: 190px;
max-width: 190px;
padding: 10px !important;
text-align: center;
vertical-align: middle;
} }
.product-thumb-button { .product-thumb-button {
width: 54px; width: 162px;
height: 72px; height: 216px;
border: 1px solid #d9dde3; border: 1px solid #d9dde3;
border-radius: 6px; border-radius: 8px;
background: #fff; background: #fff;
padding: 4px; padding: 0;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
} }
.product-thumb, .product-thumb,
.product-thumb-placeholder { .product-thumb-placeholder {
width: 46px; width: 100%;
height: 64px; height: 100%;
} }
.product-thumb { .product-thumb {
display: block; display: block;
object-fit: contain; object-fit: contain;
object-position: center;
} }
.product-thumb-placeholder { .product-thumb-placeholder {
+14 -35
View File
@@ -423,7 +423,6 @@ const productImageListByCode = ref({})
const productImageListLoading = ref({}) const productImageListLoading = ref({})
const productImageFallbackByKey = ref({}) const productImageFallbackByKey = ref({})
const productImageContentLoading = ref({}) const productImageContentLoading = ref({})
const productImageBlobUrls = ref([])
const productImageListBlockedUntil = ref(0) const productImageListBlockedUntil = ref(0)
const productCardDialog = ref(false) const productCardDialog = ref(false)
const productCardData = ref({}) const productCardData = ref({})
@@ -637,14 +636,9 @@ function resolveProductImageUrl(item) {
} }
let contentUrl = '' let contentUrl = ''
const imageId = Number(item.id || item.ID || 0) const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (Number.isFinite(imageId) && imageId > 0) { if (contentURL.startsWith('/api/')) contentUrl = contentURL
contentUrl = `/api/product-images/${imageId}/content` else if (contentURL.startsWith('/')) contentUrl = `/api${contentURL}`
} else {
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 || '') const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
let publicUrl = '' let publicUrl = ''
@@ -708,23 +702,12 @@ function clearGalleryQueryIndex() {
async function resolveProductImageUrlForCarousel(item) { async function resolveProductImageUrlForCarousel(item) {
const resolved = resolveProductImageUrl(item) const resolved = resolveProductImageUrl(item)
const contentUrl = String(resolved.contentUrl || '').trim() const contentUrl = String(resolved.contentUrl || '').trim()
if (contentUrl) { if (contentUrl) return contentUrl
try {
const blobRes = await api.get(contentUrl, { baseURL: '', responseType: 'blob' }) const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim()
const blob = blobRes?.data if (directUrl) return directUrl
if (blob instanceof Blob) {
const objectUrl = URL.createObjectURL(blob) return ''
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()
} }
function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') { function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
@@ -774,9 +757,9 @@ async function flushProductImageBatch() {
const list = Array.isArray(item.images) ? item.images : [] const list = Array.isArray(item.images) ? item.images : []
const first = list[0] || null const first = list[0] || null
const resolved = resolveProductImageUrl(first) 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() 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 if (queued) productImageListByCode.value[queued.listKey] = list
delete productImageLoading.value[key] delete productImageLoading.value[key]
} }
@@ -865,11 +848,11 @@ async function ensureProductImage(code, color, secondColor = '', dim1Id = '', di
productImageCache.value[key] = String( productImageCache.value[key] = String(
preferredCardUrl || preferredCardUrl ||
primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || primaryResolved.contentUrl || primaryResolved.contentUrl || primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl ||
secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || secondaryResolved.contentUrl || secondaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl ||
'' ''
).trim() ).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) { } catch (err) {
console.warn('[ProductStockByAttributes] product image fetch failed', { code, color, err }) console.warn('[ProductStockByAttributes] product image fetch failed', { code, color, err })
productImageCache.value[key] = '' productImageCache.value[key] = ''
@@ -1581,10 +1564,6 @@ onUnmounted(() => {
clearTimeout(filterOptionsDebounceTimer) clearTimeout(filterOptionsDebounceTimer)
filterOptionsDebounceTimer = null filterOptionsDebounceTimer = null
} }
for (const url of productImageBlobUrls.value) {
try { URL.revokeObjectURL(url) } catch {}
}
productImageBlobUrls.value = []
}) })
</script> </script>
+14 -35
View File
@@ -390,7 +390,6 @@ const productImageListByCode = ref({})
const productImageListLoading = ref({}) const productImageListLoading = ref({})
const productImageFallbackByKey = ref({}) const productImageFallbackByKey = ref({})
const productImageContentLoading = ref({}) const productImageContentLoading = ref({})
const productImageBlobUrls = ref([])
const productImageListBlockedUntil = ref(0) const productImageListBlockedUntil = ref(0)
const productCardDialog = ref(false) const productCardDialog = ref(false)
const productCardData = ref({}) const productCardData = ref({})
@@ -618,14 +617,9 @@ function resolveProductImageUrl(item) {
} }
let contentUrl = '' let contentUrl = ''
const imageId = Number(item.id || item.ID || 0) const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (Number.isFinite(imageId) && imageId > 0) { if (contentURL.startsWith('/api/')) contentUrl = contentURL
contentUrl = `/api/product-images/${imageId}/content` else if (contentURL.startsWith('/')) contentUrl = `/api${contentURL}`
} else {
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 || '') const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
let publicUrl = '' let publicUrl = ''
@@ -687,23 +681,12 @@ function clearGalleryQueryIndex() {
async function resolveProductImageUrlForCarousel(item) { async function resolveProductImageUrlForCarousel(item) {
const resolved = resolveProductImageUrl(item) const resolved = resolveProductImageUrl(item)
const contentUrl = String(resolved.contentUrl || '').trim() const contentUrl = String(resolved.contentUrl || '').trim()
if (contentUrl) { if (contentUrl) return contentUrl
try {
const blobRes = await api.get(contentUrl, { baseURL: '', responseType: 'blob' }) const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim()
const blob = blobRes?.data if (directUrl) return directUrl
if (blob instanceof Blob) {
const objectUrl = URL.createObjectURL(blob) return ''
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()
} }
function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') { function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
@@ -753,9 +736,9 @@ async function flushProductImageBatch() {
const list = Array.isArray(item.images) ? item.images : [] const list = Array.isArray(item.images) ? item.images : []
const first = list[0] || null const first = list[0] || null
const resolved = resolveProductImageUrl(first) 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() 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 if (queued) productImageListByCode.value[queued.listKey] = list
delete productImageLoading.value[key] delete productImageLoading.value[key]
} }
@@ -841,11 +824,11 @@ async function ensureProductImage(code, color, secondColor = '', dim1Id = '', di
const secondaryResolved = resolveProductImageUrl(secondaryItem) const secondaryResolved = resolveProductImageUrl(secondaryItem)
productImageCache.value[key] = String( productImageCache.value[key] = String(
preferredCardUrl || preferredCardUrl ||
primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || primaryResolved.contentUrl || primaryResolved.contentUrl || primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl ||
secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || secondaryResolved.contentUrl || secondaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl ||
'' ''
).trim() ).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) { } catch (err) {
console.warn('[ProductStockQuery] product image fetch failed', { code, color, err }) console.warn('[ProductStockQuery] product image fetch failed', { code, color, err })
productImageCache.value[key] = '' productImageCache.value[key] = ''
@@ -1379,10 +1362,6 @@ function resetForm() {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('mousemove', onFullscreenMouseMove) window.removeEventListener('mousemove', onFullscreenMouseMove)
window.removeEventListener('mouseup', onFullscreenMouseUp) window.removeEventListener('mouseup', onFullscreenMouseUp)
for (const url of productImageBlobUrls.value) {
try { URL.revokeObjectURL(url) } catch {}
}
productImageBlobUrls.value = []
}) })
onMounted(async () => { onMounted(async () => {
@@ -40,6 +40,14 @@
:loading="loading" :loading="loading"
@click="fetchRows" @click="fetchRows"
/> />
<q-btn
label="Excel'e Aktar"
icon="download"
color="primary"
outline
:disable="loading || rows.length === 0"
@click="exportVisibleRows"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -141,7 +149,7 @@
</template> </template>
<script setup> <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 { useRouter } from 'vue-router'
import { usePermission } from 'src/composables/usePermission' import { usePermission } from 'src/composables/usePermission'
import { get, extractApiErrorDetail } from 'src/services/api' import { get, extractApiErrorDetail } from 'src/services/api'
@@ -168,6 +176,10 @@ const dateColumns = new Set(['Tarihi', 'dteKayitTarihi', 'dteGuncellemeTarihi',
const columns = [ const columns = [
{ name: 'open', label: '', field: 'open', align: 'center', sortable: false, style: 'width:3%', headerStyle: 'width:3%' }, { 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: '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: '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: '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%' }, { 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 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) { function getColumnFilter (name) {
if (!columnFilters[name]) { if (!columnFilters[name]) {
@@ -293,9 +329,11 @@ function clearAllColumnFilters () {
} }
let searchTimer = null let searchTimer = null
let filterWatchEnabled = false
watch( watch(
() => filters.search, () => filters.search,
() => { () => {
if (!filterWatchEnabled) return
clearTimeout(searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => {
fetchRows() fetchRows()
@@ -348,19 +386,66 @@ function clearFilters () {
fetchRows() 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) { function openRow (row) {
const urunKodu = String(row?.UrunKodu || '').trim() const urunKodu = String(row?.UrunKodu || '').trim()
if (!urunKodu) return if (!urunKodu) return
persistListState()
router.push({ router.push({
name: 'production-product-costing-has-cost-history', name: 'production-product-costing-has-cost-history',
query: { urun_kodu: urunKodu } query: { urun_kodu: urunKodu }
}) })
} }
onMounted(() => { onMounted(async () => {
if (!canReadOrder.value) return if (!canReadOrder.value) return
fetchRows() restoreListState()
await nextTick()
filterWatchEnabled = true
await fetchRows()
}) })
</script> </script>
@@ -457,7 +457,7 @@
<div v-else class="column q-gutter-md pcd-content-body"> <div v-else class="column q-gutter-md pcd-content-body">
<div <div
v-for="(grp, gi) in detailGroups" v-for="(grp, gi) in detailGroupsWithTotals"
:key="groupKey(grp, gi)" :key="groupKey(grp, gi)"
class="pcd-group-card" class="pcd-group-card"
> >
@@ -469,7 +469,11 @@
</span> </span>
</div> </div>
<div class="sub-right pcd-sub-right-clickable" @click="toggleGroup(grp, gi)"> <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 <q-icon
:name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'" :name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'"
size="18px" size="18px"
@@ -1557,8 +1561,14 @@ function ensureBeforeUnloadGuard (enabled) {
return '' 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) => { const toolbarSummary = computed(() => flatDetailRows.value.reduce((acc, row) => {
if (!row?.maliyeteDahil) return acc if (!shouldIncludeRowInCostingTotal(row)) return acc
acc.tryTotal += resolveRowTRYTutar(row) acc.tryTotal += resolveRowTRYTutar(row)
acc.usdTotal += resolveRowUSDTutar(row) acc.usdTotal += resolveRowUSDTutar(row)
acc.eurTotal += resolveRowEURTutar(row) acc.eurTotal += resolveRowEURTutar(row)
@@ -2694,7 +2704,9 @@ function normalizeDetailRows (items, groupName = '') {
// Use fiyat_girilen which is the unit price in original currency. // Use fiyat_girilen which is the unit price in original currency.
inputPrice: x?.inputPrice ?? normalizeInputPrice(x?.fiyat_girilen), inputPrice: x?.inputPrice ?? normalizeInputPrice(x?.fiyat_girilen),
inputPricePrBr: originalCurrency, 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), cmPriceTypeId: normalizeCMPriceTypeId(x?.cmPriceTypeId ?? x?.cm_price_type_id, groupName || x?.sAciklama3),
draftChanged: Boolean(x?.draftChanged), draftChanged: Boolean(x?.draftChanged),
priceUpdateState: String(x?.priceUpdateState || '').trim() priceUpdateState: String(x?.priceUpdateState || '').trim()
@@ -2796,6 +2808,13 @@ function recalculateDetailRow (row, options = {}) {
row.lTutarGBP = row.gbpTutar row.lTutarGBP = row.gbpTutar
row.lTutarTL = row.lTutar 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) { if (!options.preserveInputs) {
row.miktarInput = normalizeQuantityInput(quantity) row.miktarInput = normalizeQuantityInput(quantity)
row.inputPrice = normalizeInputPrice(inputPrice) row.inputPrice = normalizeInputPrice(inputPrice)
@@ -2817,58 +2836,124 @@ function recalculateDetailRow (row, options = {}) {
} }
function recalculateAllDetailRows () { function recalculateAllDetailRows () {
detailGroups.value = detailGroups.value.map(grp => ({ // Preserve row identity. QTable editors and the computed group totals must
...grp, // observe the exact same reactive row objects. Replacing rows with
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => recalculateDetailRow({ ...row }, { preserveInputs: true })) // `{ ...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) { function resolveRowTRYTutar (row) {
const tryAmount = Number(row?.lTutar || 0) const quantity = resolveNumericRowQuantity(row)
return Number.isFinite(tryAmount) ? tryAmount : 0 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) { function resolveRowEURTutar (row) {
const eurAmount = Number(row?.eurTutar || 0) return parseMoneyInput(row?.eurTutar)
return Number.isFinite(eurAmount) ? eurAmount : 0
} }
function resolveRowGBPTutar (row) { function resolveRowGBPTutar (row) {
const gbpAmount = Number(row?.gbpTutar || 0) return parseMoneyInput(row?.gbpTutar)
return Number.isFinite(gbpAmount) ? gbpAmount : 0
} }
function resolveRowUSDTutar (row) { function resolveRowUSDTutar (row) {
const usdAmount = Number(row?.usdTutar || 0) const quantity = resolveNumericRowQuantity(row)
if (Number.isFinite(usdAmount)) return usdAmount const inputPrice = resolveNumericRowInputPrice(row)
const inputCurrency = resolveInputCurrency(row)
const liveAmount = resolveUSDUnitPriceByInput(
inputPrice,
inputCurrency,
resolveExchangeRateValue('USD'),
resolveExchangeRateValue('EUR'),
resolveExchangeRateValue('GBP')
) * quantity
const miktar = resolveNumericRowQuantity(row) if (Number.isFinite(liveAmount) && inputPrice > 0) return liveAmount
const dovizFiyati = Number(row?.lDovizFiyati || 0)
const calc = miktar * dovizFiyati 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 return Number.isFinite(calc) ? calc : 0
} }
function shouldIgnoreGroupMaliyeteDahil (grp) {
return isCMGroupName(grp?.sAciklama3)
}
function shouldIncludeRowInGroupTotal (grp, row) { 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) { function resolveGroupRowTRYAmount (row) {
const items = Array.isArray(grp?.items) ? grp.items : [] const columnAmount = parseMoneyInput(row?.lTutar)
return items.reduce((acc, row) => acc + (shouldIncludeRowInGroupTotal(grp, row) ? resolveRowTRYTutar(row) : 0), 0) if (columnAmount !== 0) return columnAmount
const liveAmount = resolveRowTRYTutar(row)
if (liveAmount !== 0) return liveAmount
return parseMoneyInput(row?.__lastValidTRYAmount ?? row?.lTutarTL)
} }
function resolveGroupUSDTutar (grp) { function resolveGroupRowUSDAmount (row) {
const items = Array.isArray(grp?.items) ? grp.items : [] const columnAmount = parseMoneyInput(row?.usdTutar ?? row?.lTutarUSD)
return items.reduce((acc, row) => acc + (shouldIncludeRowInGroupTotal(grp, row) ? resolveRowUSDTutar(row) : 0), 0) 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) { function resolveGroupQuantity (grp) {
const items = Array.isArray(grp?.items) ? grp.items : [] return (Array.isArray(grp?.items) ? grp.items : []).reduce((total, row) => (
return items.reduce((acc, row) => acc + (Number(row?.lMiktar || 0) || 0), 0) total + resolveNumericRowQuantity(row)
), 0)
} }
function groupKey (grp, gi) { function groupKey (grp, gi) {
@@ -3244,7 +3329,7 @@ function onRowQuantityInput (row, value) {
} }
function triggerUIUpdate () { function triggerUIUpdate () {
detailGroups.value = [...detailGroups.value] // detailGroupsWithTotals is computed from the reactive row fields.
} }
function normalizeRowQuantityDisplay (row) { function normalizeRowQuantityDisplay (row) {
@@ -3342,25 +3427,26 @@ async function fetchBulkItemPrices () {
} }
let appliedCount = 0 let appliedCount = 0
detailGroups.value = detailGroups.value.map(grp => ({ detailGroups.value.forEach(grp => {
...grp, const items = Array.isArray(grp?.items) ? grp.items : []
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => { items.forEach(row => {
const match = updates.find(update => rowMatchesBulkUpdate(row, update)) const match = updates.find(update => rowMatchesBulkUpdate(row, update))
if (!match) return row if (!match) return
appliedCount += 1 appliedCount += 1
return recalculateDetailRow({ row.inputPrice = match.inputPrice
...row, row.fiyat_girilen = match.fiyat_girilen
inputPrice: match.inputPrice, row.inputPricePrBr = match.inputPricePrBr
fiyat_girilen: match.fiyat_girilen, row.fiyat_doviz = match.fiyat_doviz
inputPricePrBr: match.inputPricePrBr, recalculateDetailRow(row, {
fiyat_doviz: match.fiyat_doviz
}, {
priceType: match.priceType || 'SAF', priceType: match.priceType || 'SAF',
updateState: 'bulk', updateState: 'bulk',
markChanged: true 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({ $q.notify({
type: appliedCount > 0 ? 'positive' : 'warning', type: appliedCount > 0 ? 'positive' : 'warning',
@@ -3400,14 +3486,14 @@ async function fetchBulkItemPrices () {
}) })
let filled = 0 let filled = 0
detailGroups.value = detailGroups.value.map(grp => ({ detailGroups.value.forEach(grp => {
...grp, const items = Array.isArray(grp?.items) ? grp.items : []
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => { items.forEach(row => {
if (!row?.requiredPlaceholder) return row if (!row?.requiredPlaceholder) return
if (isCMGroupName(row?.sAciklama3) || normalizeGroupName(row?.sAciklama3) === 'FABRIC') return row if (isCMGroupName(row?.sAciklama3) || normalizeGroupName(row?.sAciklama3) === 'FABRIC') return
const no = parseInt(String(row?.nHammaddeTuruNo || '').trim() || '0', 10) || 0 const no = parseInt(String(row?.nHammaddeTuruNo || '').trim() || '0', 10) || 0
const hit = byNo[no] const hit = byNo[no]
if (!hit) return row if (!hit) return
const next = { ...row } const next = { ...row }
const hasCode = String(next.sKodu || '').trim() !== '' const hasCode = String(next.sKodu || '').trim() !== ''
@@ -3427,20 +3513,20 @@ async function fetchBulkItemPrices () {
// Only mark if something changed // Only mark if something changed
const changed = (next.sKodu !== row.sKodu) || (next.sAciklama !== row.sAciklama) || (next.inputPrice !== row.inputPrice) || (next.inputPricePrBr !== row.inputPricePrBr) 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 filled += 1
return recalculateDetailRow({ Object.assign(row, next, {
...next, __autoFilledFromPrev: true,
__autoFilledFromPrev: true
,
__autoFilledFromPrevSameFirma: Boolean(hit?.is_same_firma || hit?.isSameFirma) __autoFilledFromPrevSameFirma: Boolean(hit?.is_same_firma || hit?.isSameFirma)
}, { })
recalculateDetailRow(row, {
priceType: 'PREV', priceType: 'PREV',
updateState: 'autofill', updateState: 'autofill',
markChanged: true markChanged: true
}) })
}) })
})) })
triggerUIUpdate()
if (filled > 0) { if (filled > 0) {
$q.notify({ $q.notify({
@@ -3457,6 +3543,11 @@ async function fetchBulkItemPrices () {
error: String(e?.message || e || '') 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) { } catch (err) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
@@ -3928,10 +4019,14 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) {
const groupName = normalizeGroupName(meta?.groupName || '') const groupName = normalizeGroupName(meta?.groupName || '')
const hammaddeAdi = String(meta?.hammaddeAdi || '').trim() const hammaddeAdi = String(meta?.hammaddeAdi || '').trim()
const effectiveGroupName = groupName || 'TANIMSIZ' const effectiveGroupName = groupName || 'TANIMSIZ'
const mtBolumID = (meta?.mtBolumID > 0 ? meta.mtBolumID : mappingMtBolumID) || 0 // The product-type mapping is authoritative for the required part. A raw
const metaParcaCandidate = normalizeGroupName((meta?.parcaAdi || '').trim()) // material type can be required for multiple parts, while its master
// Defensive: if backend/lookup accidentally returns group label (DT/TP/...) as part name, ignore it. // record only carries one default MT section. Using that default here
const desiredParcaAdi = normalizeGroupName((metaParcaCandidate && !isKnownGroupName(metaParcaCandidate)) ? metaParcaCandidate : mappingParcaAdi) // 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 => { const anyMatch = flatDetailRows.value.find(r => {
if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false 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 we created a new OnML (no-cost), switch to has-cost detail mode.
if (isNoCostDetail.value && newOnMLNo > 0) { if (isNoCostDetail.value && newOnMLNo > 0) {
router.replace({ await router.replace({
name: 'production-product-costing-has-cost-detail', name: 'production-product-costing-has-cost-detail',
query: { query: {
n_onml_no: String(newOnMLNo), n_onml_no: String(newOnMLNo),
urun_kodu: String(header?.UrunKodu || productCode.value || '').trim() 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 return
} }
@@ -5078,6 +5191,24 @@ async function saveChanges () {
window.setTimeout(() => { window.setTimeout(() => {
try { refreshLast10Warnings() } catch {} try { refreshLast10Warnings() } catch {}
}, 1200) }, 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) { } catch (e) {
// Surface backend message (http.Error text) when available. // Surface backend message (http.Error text) when available.
const msg = String( const msg = String(
@@ -5526,8 +5657,8 @@ watch(
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 8px 10px; padding: 8px 10px;
min-height: 42px; min-height: 46px;
height: 42px; height: auto;
border-top: 1px solid #d6c06a; border-top: 1px solid #d6c06a;
border-bottom: 1px solid #d6c06a; border-bottom: 1px solid #d6c06a;
background: linear-gradient(90deg, #fffbe9 0%, #fff4c4 50%, #fff1b0 100%); background: linear-gradient(90deg, #fffbe9 0%, #fff4c4 50%, #fff1b0 100%);
@@ -5549,7 +5680,9 @@ watch(
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: nowrap; flex-wrap: wrap;
row-gap: 4px;
max-width: 70%;
} }
.pcd-sub-header .sub-left { .pcd-sub-header .sub-left {
display: flex; display: flex;
@@ -5566,8 +5699,31 @@ watch(
user-select: none; user-select: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px 10px;
flex-wrap: nowrap; 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; white-space: nowrap;
} }
.pcd-sub-mt-qty { .pcd-sub-mt-qty {
@@ -5577,6 +5733,18 @@ watch(
font-weight: 900; 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) { .pcd-detail-table :deep(.q-table__middle) {
overflow: visible !important; overflow: visible !important;
} }
@@ -5717,12 +5885,69 @@ watch(
} }
.pcd-history-table :deep(.q-table thead th) { .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) { .pcd-history-table :deep(.q-table tbody td) {
font-size: 12px; font-size: 9.5px;
vertical-align: top; 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) { .pcd-history-table :deep(.pcd-history-row-purchase td) {
+102 -4
View File
@@ -49,6 +49,14 @@
:loading="loading" :loading="loading"
@click="fetchRows" @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"> <div class="npc-missing-count text-caption text-grey-7">
Maliyeti Girilmemis Satir Sayisi: <b>{{ missingCostRowCount }}</b> Maliyeti Girilmemis Satir Sayisi: <b>{{ missingCostRowCount }}</b>
</div> </div>
@@ -176,7 +184,7 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { usePermission } from 'src/composables/usePermission' import { usePermission } from 'src/composables/usePermission'
@@ -244,6 +252,9 @@ const columns = [
style: 'width:7%', style: 'width:7%',
headerStyle: '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', name: 'sMModelKodu',
label: 'Model Kodu', label: 'Model Kodu',
@@ -262,6 +273,34 @@ const columns = [
] ]
const columnFilters = reactive({}) 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) { function getColumnFilter (name) {
if (!columnFilters[name]) { if (!columnFilters[name]) {
@@ -276,7 +315,11 @@ function getColumnFilter (name) {
} }
function isNumericColumn (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) { function parseNumberFilter (value) {
@@ -384,9 +427,11 @@ function clearAllColumnFilters () {
} }
let searchTimer = null let searchTimer = null
let filterWatchEnabled = false
watch( watch(
() => filters.search, () => filters.search,
() => { () => {
if (!filterWatchEnabled) return
clearTimeout(searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => {
fetchRows() fetchRows()
@@ -397,6 +442,7 @@ watch(
watch( watch(
() => filters.fromDate, () => filters.fromDate,
() => { () => {
if (!filterWatchEnabled) return
fetchRows() fetchRows()
} }
) )
@@ -430,6 +476,49 @@ function clearFilters () {
fetchRows() 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) { function openRow (row) {
const productCode = String(row?.sMModelKodu || '').trim() const productCode = String(row?.sMModelKodu || '').trim()
const recipeCode = String(row?.sKodu || '').trim() const recipeCode = String(row?.sKodu || '').trim()
@@ -449,6 +538,8 @@ function openRow (row) {
recipe_code: recipeCode recipe_code: recipeCode
}) })
persistListState()
router.push({ router.push({
name: 'production-product-costing-has-cost-detail', name: 'production-product-costing-has-cost-detail',
query: { query: {
@@ -460,9 +551,16 @@ function openRow (row) {
}) })
} }
onMounted(() => { onMounted(async () => {
if (!canReadOrder.value) return 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> </script>
+88 -28
View File
@@ -4,37 +4,38 @@ import api from 'src/services/api'
const GROUPED_TTL_MS = 2 * 60 * 1000 const GROUPED_TTL_MS = 2 * 60 * 1000
const IMAGE_TTL_MS = 15 * 60 * 1000 const IMAGE_TTL_MS = 15 * 60 * 1000
function normalizeUploadsPath (storagePath) { function resolveProductImageUrl (item) {
const raw = String(storagePath || '').trim() return resolveProductImageUrls(item)[0] || ''
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) { function resolveProductImageUrls (item) {
if (!item || typeof item !== 'object') return '' if (!item || typeof item !== 'object') return []
const urls = []
const contentURL = String(item.content_url || item.ContentURL || '').trim() const addURL = value => {
if (contentURL.startsWith('/api/')) return contentURL const url = String(value || '').trim()
if (contentURL.startsWith('/')) return `/api${contentURL}` if (url && !urls.includes(url)) urls.push(url)
}
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim() const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
if (thumbURL) return thumbURL addURL(thumbURL)
const fullURL = String(item.full_url || item.fullUrl || '').trim() const fullURL = String(item.full_url || item.fullUrl || '').trim()
if (fullURL) return fullURL addURL(fullURL)
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '') const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (uploadsPath) return uploadsPath if (contentURL.startsWith('/api/')) addURL(contentURL)
else if (contentURL.startsWith('/')) addURL(`/api${contentURL}`)
return urls
}
const fileName = String(item.file_name || item.FileName || '').trim() function normalizedFilterMap (filters = {}) {
return fileName ? `/uploads/image/${fileName}` : '' 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) { function normalizedList (value, sort = false) {
@@ -49,6 +50,9 @@ export const useProductPerformanceStore = defineStore('product-performance-store
groupedLoadedAtByKey: {}, groupedLoadedAtByKey: {},
groupedLoadingByKey: {}, groupedLoadingByKey: {},
groupedInFlightByKey: {}, groupedInFlightByKey: {},
groupedFilterOptionsByKey: {},
groupedFilterOptionsLoadedAtByKey: {},
groupedFilterOptionsInFlightByKey: {},
imageUrlByKey: {}, imageUrlByKey: {},
imageListByKey: {}, imageListByKey: {},
imageLoadedAtByKey: {} imageLoadedAtByKey: {}
@@ -65,8 +69,23 @@ export const useProductPerformanceStore = defineStore('product-performance-store
groupedCacheKey (params = {}) { groupedCacheKey (params = {}) {
return JSON.stringify({ return JSON.stringify({
mode: String(params.mode || ''), mode: String(params.mode || ''),
mainGroup: String(params.urunAnaGrubu || params.urun_ana_grubu || ''),
groupLevels: normalizedList(params.groupLevels), groupLevels: normalizedList(params.groupLevels),
expandedKeys: normalizedList(params.expandedKeys, true), 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) limit: Number(params.limit || 0)
}) })
}, },
@@ -89,7 +108,8 @@ export const useProductPerformanceStore = defineStore('product-performance-store
console.info(`${logPrefix} in-flight reuse`, { console.info(`${logPrefix} in-flight reuse`, {
mode: params.mode, mode: params.mode,
groupLevels: normalizedList(params.groupLevels), 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] return this.groupedInFlightByKey[key]
} }
@@ -100,17 +120,27 @@ export const useProductPerformanceStore = defineStore('product-performance-store
mode: params.mode, mode: params.mode,
groupLevels: normalizedList(params.groupLevels), 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),
limit: params.limit limit: params.limit
}) })
const request = api.post('/pricing/product-performance/grouped', { const request = api.post('/pricing/product-performance/grouped', {
mode: params.mode, 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), 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), 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 limit: params.limit
}, { }, {
params: { params: {
mode: params.mode, mode: params.mode,
limit: params.limit limit: params.limit,
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 timeout: 90000
}).then(resp => { }).then(resp => {
@@ -124,10 +154,11 @@ export const useProductPerformanceStore = defineStore('product-performance-store
}) })
return rows return rows
}).catch(err => { }).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, mode: params.mode,
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)), elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)),
message: err?.response?.data || err?.message || err message
}) })
throw err throw err
}).finally(() => { }).finally(() => {
@@ -143,6 +174,35 @@ export const useProductPerformanceStore = defineStore('product-performance-store
return request 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 = []) { setImageCache (key, urls = []) {
const normalizedKey = String(key || '').trim() const normalizedKey = String(key || '').trim()
if (!normalizedKey) return if (!normalizedKey) return
@@ -186,7 +246,7 @@ export const useProductPerformanceStore = defineStore('product-performance-store
const key = String(item?.key || '').trim() const key = String(item?.key || '').trim()
if (!key) continue if (!key) continue
returned.add(key) 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) this.setImageCache(key, urls)
lists[key] = urls lists[key] = urls
urlsByKey[key] = urls[0] || '' urlsByKey[key] = urls[0] || ''