Compare commits

..
91 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
M_Kececi a45478d15f product performance grouped kpi improvements 2026-07-03 19:53:16 +03:00
M_Kececi b133d43ebe product performance grouped kpi improvements 2026-07-03 17:43:13 +03:00
M_Kececi 9fee8c8ab2 product performance grouped kpi improvements 2026-07-03 17:12:30 +03:00
M_Kececi 95d72ec74b product performance grouped kpi improvements 2026-07-03 15:39:39 +03:00
M_Kececi 4cd1b118d0 product performance grouped kpi improvements 2026-07-03 14:49:50 +03:00
M_Kececi 2f885a4841 product performance grouped kpi improvements 2026-07-03 14:34:30 +03:00
M_Kececi 4aebb36ff2 product performance grouped kpi improvements 2026-07-03 06:01:35 +03:00
M_Kececi fd086d668f product performance grouped kpi improvements 2026-07-03 05:25:05 +03:00
M_Kececi 5288f19976 product performance grouped kpi improvements 2026-07-03 04:27:42 +03:00
M_Kececi 60eef45769 product performance grouped kpi improvements 2026-07-03 03:58:15 +03:00
27 changed files with 9676 additions and 1721 deletions
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="GoModuleSettings" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
+2
View File
@@ -3,6 +3,8 @@ bssapp
# Env
.env
.env.local
.env.argebaggi.local
mail.env
# Runtime fonts
+2 -1
View File
@@ -66,10 +66,11 @@ func main() {
log.Fatalf("refresh failed: %v", err)
}
fmt.Printf("product performance refresh done: sales=%d stock=%d kpi=%d\n",
fmt.Printf("product performance refresh done: sales=%d stock=%d kpi=%d snapshots=%d\n",
result.SalesRows,
result.StockRows,
result.KpiRows,
result.SnapshotRows,
)
}
+39
View File
@@ -14,6 +14,7 @@ import (
var MssqlDB *sql.DB
var UretimDB *sql.DB
var ArgeBaggiDB *sql.DB
func envInt(name string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(name))
@@ -158,3 +159,41 @@ func ConnectMSSQLUretim() error {
func GetUretimDB() *sql.DB {
return UretimDB
}
// ConnectMSSQLArgeBaggi initializes the optional ArgeBAGGI connection.
// Credentials must be supplied through ARGEBAGGI_MSSQL_CONN; never keep them
// in source control.
func ConnectMSSQLArgeBaggi() error {
connString := strings.TrimSpace(os.Getenv("ARGEBAGGI_MSSQL_CONN"))
if connString == "" {
return fmt.Errorf("ARGEBAGGI_MSSQL_CONN tanimli degil")
}
connectionTimeoutSec := envInt("ARGEBAGGI_MSSQL_CONNECTION_TIMEOUT_SEC", 10)
dialTimeoutSec := envInt("ARGEBAGGI_MSSQL_DIAL_TIMEOUT_SEC", connectionTimeoutSec)
connString = ensureMSSQLTimeouts(connString, connectionTimeoutSec, dialTimeoutSec)
var err error
ArgeBaggiDB, err = sql.Open("sqlserver", connString)
if err != nil {
return fmt.Errorf("ArgeBAGGI MSSQL baglanti hatasi: %w", err)
}
ArgeBaggiDB.SetMaxOpenConns(envInt("ARGEBAGGI_MSSQL_MAX_OPEN_CONNS", 10))
ArgeBaggiDB.SetMaxIdleConns(envInt("ARGEBAGGI_MSSQL_MAX_IDLE_CONNS", 5))
ArgeBaggiDB.SetConnMaxLifetime(time.Duration(envInt("ARGEBAGGI_MSSQL_CONN_MAX_LIFETIME_MIN", 30)) * time.Minute)
ArgeBaggiDB.SetConnMaxIdleTime(time.Duration(envInt("ARGEBAGGI_MSSQL_CONN_MAX_IDLE_MIN", 10)) * time.Minute)
if err = ArgeBaggiDB.Ping(); err != nil {
_ = ArgeBaggiDB.Close()
ArgeBaggiDB = nil
return fmt.Errorf("ArgeBAGGI MSSQL erisilemiyor: %w", err)
}
fmt.Printf("ArgeBAGGI MSSQL baglantisi basarili (connection timeout=%ds, dial timeout=%ds)\n", connectionTimeoutSec, dialTimeoutSec)
return nil
}
func GetArgeBaggiDB() *sql.DB {
return ArgeBaggiDB
}
+41
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 {
r := mux.NewRouter()
mountUploads(r)
mountSPA(r)
/*
@@ -939,6 +940,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "view",
wrapV3(routes.GetProductPerformanceHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/product-performance/export-excel", "POST",
"pricing", "view",
wrapV3(routes.ExportProductPerformanceExcelHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/product-performance/summary", "GET",
"pricing", "view",
@@ -999,6 +1005,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "view",
wrapV3(routes.GetProductPerformanceGroupedHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/product-performance/grouped/filter-options", "POST",
"pricing", "view",
wrapV3(routes.GetProductPerformanceGroupedFilterOptionsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/product-performance/sales-details", "GET",
"pricing", "view",
@@ -1379,6 +1390,7 @@ func main() {
// (e.g. SSH-tunnel PostgreSQL on 127.0.0.1:15432).
if runtime.GOOS == "windows" {
_ = godotenv.Overload(".env.local")
_ = godotenv.Overload(".env.argebaggi.local")
}
jwtSecret := os.Getenv("JWT_SECRET")
@@ -1406,6 +1418,15 @@ func main() {
log.Fatal(err)
}
}
if err := db.ConnectMSSQLArgeBaggi(); err != nil {
if strings.Contains(err.Error(), "ARGEBAGGI_MSSQL_CONN tanimli degil") {
log.Println("ArgeBAGGI DB baglantisi atlandi: ARGEBAGGI_MSSQL_CONN tanimli degil")
} else {
// This source enriches one screen; a temporary outage must not stop
// the whole application from starting.
log.Println("ArgeBAGGI DB baglantisi basarisiz:", err)
}
}
pgDB, err := db.ConnectPostgres()
if err != nil {
@@ -1507,6 +1528,26 @@ func main() {
}
func mountUploads(r *mux.Router) {
root := strings.TrimSpace(os.Getenv("BLOB_ROOT"))
if root == "" {
return
}
uploadsRoot := filepath.Join(root, "uploads")
if fi, err := os.Stat(uploadsRoot); err != nil || !fi.IsDir() {
return
}
fileServer := http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadsRoot)))
r.PathPrefix("/uploads/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Cache-Control", "public, max-age=3600")
fileServer.ServeHTTP(w, r)
})).Methods(http.MethodGet, http.MethodHead, http.MethodOptions)
}
func mountSPA(r *mux.Router) {
r.NotFoundHandler = http.HandlerFunc(spaIndex)
r.HandleFunc("/", spaIndex).Methods(http.MethodGet)
+52
View File
@@ -4,6 +4,7 @@ type ProductPerformanceRow struct {
KpiDate string `json:"kpi_date"`
ProductCode string `json:"product_code"`
ColorCode string `json:"color_code"`
ColorDescription string `json:"color_description"`
YakaKodu string `json:"yaka_kodu"`
ItemDescription string `json:"item_description"`
Kategori string `json:"kategori"`
@@ -20,14 +21,28 @@ type ProductPerformanceRow struct {
SalesQty180 float64 `json:"sales_qty_180d"`
SalesQty365 float64 `json:"sales_qty_365d"`
SalesQty730 float64 `json:"sales_qty_730d"`
SalesQtyTotal float64 `json:"sales_qty_total"`
SalesUSD30 float64 `json:"sales_usd_30d"`
SalesUSD90 float64 `json:"sales_usd_90d"`
SalesUSD180 float64 `json:"sales_usd_180d"`
SalesUSD365 float64 `json:"sales_usd_365d"`
SalesUSDTotal float64 `json:"sales_usd_total"`
AvgDailySales90 float64 `json:"avg_daily_sales_90d"`
AvgDailySales180 float64 `json:"avg_daily_sales_180d"`
AvgDailySales365 float64 `json:"avg_daily_sales_365d"`
AvgDailySalesTotal float64 `json:"avg_daily_sales_total"`
AvgStock90 float64 `json:"avg_stock_90d"`
AvgStock180 float64 `json:"avg_stock_180d"`
AvgStock365 float64 `json:"avg_stock_365d"`
AvgStockTotal float64 `json:"avg_stock_total"`
StockDays90 float64 `json:"stock_days_90d"`
StockDays180 float64 `json:"stock_days_180d"`
StockDays365 float64 `json:"stock_days_365d"`
StockDaysTotal float64 `json:"stock_days_total"`
StockTurnover90 float64 `json:"stock_turnover_90d"`
StockTurnover180 float64 `json:"stock_turnover_180d"`
StockTurnover365 float64 `json:"stock_turnover_365d"`
StockTurnoverTotal float64 `json:"stock_turnover_total"`
AvgPriceUSD90 float64 `json:"avg_price_usd_90d"`
AvgPriceUSD180 float64 `json:"avg_price_usd_180d"`
CostPriceUSD float64 `json:"cost_price_usd"`
@@ -42,10 +57,23 @@ type ProductPerformanceRow struct {
UnitProfitBase90 float64 `json:"unit_profit_base_90d"`
UnitProfitBase180 float64 `json:"unit_profit_base_180d"`
MarketCount90 int `json:"market_count_90d"`
MarketCount180 int `json:"market_count_180d"`
MarketCount365 int `json:"market_count_365d"`
MarketCountTotal int `json:"market_count_total"`
CustomerCount90 int `json:"customer_count_90d"`
CustomerCount180 int `json:"customer_count_180d"`
CustomerCount365 int `json:"customer_count_365d"`
CustomerCountTotal int `json:"customer_count_total"`
SalesIndex90 float64 `json:"sales_index_90d"`
SalesIndex180 float64 `json:"sales_index_180d"`
SalesIndex365 float64 `json:"sales_index_365d"`
SalesIndexTotal float64 `json:"sales_index_total"`
PriceIndex90 float64 `json:"price_index_90d"`
MarginIndex90 float64 `json:"margin_index_90d"`
PerformanceScore90 float64 `json:"performance_score_90d"`
PerformanceScore180 float64 `json:"performance_score_180d"`
PerformanceScore365 float64 `json:"performance_score_365d"`
PerformanceScoreTotal float64 `json:"performance_score_total"`
PerformanceScore float64 `json:"performance_score"`
PerformanceBucket string `json:"performance_bucket"`
Recommendation string `json:"recommendation"`
@@ -76,6 +104,7 @@ type ProductPerformanceGeneralRow struct {
PeriodEnd string `json:"period_end"`
ProductCode string `json:"product_code"`
ColorCode string `json:"color_code"`
ColorDescription string `json:"color_description"`
YakaKodu string `json:"yaka_kodu"`
ItemDescription string `json:"item_description"`
Kategori string `json:"kategori"`
@@ -113,6 +142,7 @@ type ProductPerformanceGeneralRow struct {
type ProductPerformanceOrderAnalysisRow struct {
ProductCode string `json:"product_code"`
ColorCode string `json:"color_code"`
ColorDescription string `json:"color_description"`
YakaKodu string `json:"yaka_kodu"`
ItemDescription string `json:"item_description"`
Kategori string `json:"kategori"`
@@ -177,6 +207,7 @@ type ProductPerformanceOrderGroupRow struct {
type ProductPerformanceOrderProductCustomerRow struct {
ProductCode string `json:"product_code"`
ColorCode string `json:"color_code"`
ColorDescription string `json:"color_description"`
YakaKodu string `json:"yaka_kodu"`
ItemDescription string `json:"item_description"`
Kategori string `json:"kategori"`
@@ -214,6 +245,7 @@ type ProductPerformanceOrderMarketDetailRow struct {
DueDate string `json:"due_date"`
ProductCode string `json:"product_code"`
ColorCode string `json:"color_code"`
ColorDescription string `json:"color_description"`
YakaKodu string `json:"yaka_kodu"`
ItemDescription string `json:"item_description"`
Kategori string `json:"kategori"`
@@ -296,6 +328,7 @@ type ProductPerformanceSalesBreakdownRow struct {
Breakdown string `json:"breakdown"`
ProductCode string `json:"product_code"`
ColorCode string `json:"color_code"`
ColorDescription string `json:"color_description"`
YakaKodu string `json:"yaka_kodu"`
ItemDescription string `json:"item_description"`
Kategori string `json:"kategori"`
@@ -309,6 +342,14 @@ type ProductPerformanceSalesBreakdownRow struct {
CustomerCode string `json:"customer_code"`
CustomerName string `json:"customer_name"`
ProductCount int `json:"product_count"`
ProductGroupCount90 int `json:"product_group_count_90d"`
ProductGroupCount180 int `json:"product_group_count_180d"`
ProductGroupCount365 int `json:"product_group_count_365d"`
ProductGroupCountTotal int `json:"product_group_count_total"`
MarketCount90 int `json:"market_count_90d"`
MarketCount180 int `json:"market_count_180d"`
MarketCount365 int `json:"market_count_365d"`
MarketCountTotal int `json:"market_count_total"`
CustomerCount90 int `json:"customer_count_90d"`
InvoiceCount90 int `json:"invoice_count_90d"`
SalesQty90 float64 `json:"sales_qty_90d"`
@@ -354,16 +395,27 @@ type ProductPerformanceSalesBreakdownRow struct {
GrossMarginBaseTotal float64 `json:"gross_margin_base_total"`
GrossMarginCostTotal float64 `json:"gross_margin_cost_total"`
StockQty float64 `json:"stock_qty"`
AvgStock90 float64 `json:"avg_stock_90d"`
AvgStock180 float64 `json:"avg_stock_180d"`
AvgStock365 float64 `json:"avg_stock_365d"`
AvgStockTotal float64 `json:"avg_stock_total"`
StockTurnover90 float64 `json:"stock_turnover_90d"`
StockTurnover180 float64 `json:"stock_turnover_180d"`
StockTurnover365 float64 `json:"stock_turnover_365d"`
StockTurnoverTotal float64 `json:"stock_turnover_total"`
HasCost bool `json:"has_cost"`
SalesIndex90 float64 `json:"sales_index_90d"`
SalesIndex180 float64 `json:"sales_index_180d"`
SalesIndex365 float64 `json:"sales_index_365d"`
SalesIndexTotal float64 `json:"sales_index_total"`
CustomerScore90 float64 `json:"customer_score_90d"`
CustomerScore180 float64 `json:"customer_score_180d"`
CustomerScore365 float64 `json:"customer_score_365d"`
CustomerScoreTotal float64 `json:"customer_score_total"`
PerformanceScore90 float64 `json:"performance_score_90d"`
PerformanceScore180 float64 `json:"performance_score_180d"`
PerformanceScore365 float64 `json:"performance_score_365d"`
PerformanceScoreTotal float64 `json:"performance_score_total"`
PerformanceScore float64 `json:"performance_score"`
PerformanceBucket string `json:"performance_bucket"`
Recommendation string `json:"recommendation"`
+7
View File
@@ -12,11 +12,18 @@ type ProductionNoCostProductRow struct {
SKullaniciAdi string `json:"sKullaniciAdi"`
SKullaniciGunc string `json:"sKullaniciAdiGunc"`
MMiktarG float64 `json:"lMMiktar_G"`
CeketDepoGiris float64 `json:"ceketDepoGiris"`
PantolonDepoGiris float64 `json:"pantolonDepoGiris"`
YelekDepoGiris float64 `json:"yelekDepoGiris"`
MModelKodu string `json:"sMModelKodu"`
}
type ProductionHasCostProductRow struct {
UretimSekli string `json:"UretimSekli"`
UrtSiparisNo string `json:"nUrtSiparisNo"`
CeketDepoGiris float64 `json:"ceketDepoGiris"`
PantolonDepoGiris float64 `json:"pantolonDepoGiris"`
YelekDepoGiris float64 `json:"yelekDepoGiris"`
NOnMLNo string `json:"nOnMLNo"`
UrunKodu string `json:"UrunKodu"`
UrunAdi string `json:"UrunAdi"`
+94 -2
View File
@@ -26,6 +26,12 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
deltaHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_DELTA_HHMM", "02:00")
deltaTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_TIMEOUT_HOURS", 6, 1, 24)
runOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_DELTA_RUN_ON_STARTUP", false)
fullEnabled := productPerformanceEnvBool("PRODUCT_PERFORMANCE_FULL_ENABLED", false)
fullWeekday := productPerformanceEnvWeekday("PRODUCT_PERFORMANCE_FULL_WEEKDAY", time.Saturday)
fullHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_FULL_HHMM", "16:00")
fullStartDate := productPerformanceEnvDate("PRODUCT_PERFORMANCE_FULL_START_DATE", time.Date(2022, 1, 1, 0, 0, 0, 0, time.Local))
fullTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_FULL_TIMEOUT_HOURS", 18, 1, 72)
fullRunOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_FULL_RUN_ON_STARTUP", false)
var running int32
runDelta := func(reason string) {
@@ -49,12 +55,40 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err)
return
}
log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d duration_ms=%d",
reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.DurationMS)
log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d snapshots=%d duration_ms=%d",
reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.SnapshotRows, result.DurationMS)
}
runFull := func(reason string) {
if !atomic.CompareAndSwapInt32(&running, 0, 1) {
log.Printf("[ProductPerformanceJob] skip (%s): already running", reason)
return
}
defer atomic.StoreInt32(&running, 0)
endDate := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(fullTimeoutHours)*time.Hour)
defer cancel()
result, err := queries.RefreshProductPerformance(ctx, pgDB, queries.ProductPerformanceRefreshRequest{
Mode: "full",
Stage: "all",
StartDate: fullStartDate,
EndDate: endDate,
})
if err != nil {
log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err)
return
}
log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d snapshots=%d duration_ms=%d",
reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.SnapshotRows, result.DurationMS)
}
log.Printf("[ProductPerformanceJob] scheduled daily_delta=%s lookback_days=%d run_delta_on_startup=%t",
deltaHHMM, deltaDays, runOnStartup)
if fullEnabled {
log.Printf("[ProductPerformanceJob] scheduled weekly_full=%s %s start_date=%s timeout_hours=%d run_full_on_startup=%t",
fullWeekday.String(), fullHHMM, fullStartDate.Format("2006-01-02"), fullTimeoutHours, fullRunOnStartup)
}
go func() {
if runOnStartup {
@@ -69,6 +103,21 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
runDelta("daily-delta")
}
}()
if fullEnabled {
go func() {
if fullRunOnStartup {
time.Sleep(30 * time.Second)
runFull("startup-full")
}
for {
next := productPerformanceNextWeekly(time.Now(), fullWeekday, fullHHMM)
log.Printf("[ProductPerformanceJob] weekly full next_at=%s in=%s", next.Format(time.RFC3339), time.Until(next).Round(time.Second))
time.Sleep(time.Until(next))
runFull("weekly-full")
}
}()
}
}
func productPerformanceNextDaily(now time.Time, hhmm string) time.Time {
@@ -80,6 +129,16 @@ func productPerformanceNextDaily(now time.Time, hhmm string) time.Time {
return next
}
func productPerformanceNextWeekly(now time.Time, weekday time.Weekday, hhmm string) time.Time {
hour, minute := productPerformanceParseHHMM(hhmm, 16, 0)
daysUntil := (int(weekday) - int(now.Weekday()) + 7) % 7
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()).AddDate(0, 0, daysUntil)
if !next.After(now) {
next = next.AddDate(0, 0, 7)
}
return next
}
func productPerformanceParseHHMM(raw string, fallbackHour, fallbackMinute int) (int, int) {
parts := strings.Split(strings.TrimSpace(raw), ":")
if len(parts) != 2 {
@@ -120,3 +179,36 @@ func productPerformanceEnvBool(name string, fallback bool) bool {
}
return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
}
func productPerformanceEnvDate(name string, fallback time.Time) time.Time {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
parsed, err := time.ParseInLocation("2006-01-02", raw, time.Local)
if err != nil {
return fallback
}
return parsed
}
func productPerformanceEnvWeekday(name string, fallback time.Weekday) time.Weekday {
switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
case "sunday", "sun", "pazar", "0":
return time.Sunday
case "monday", "mon", "pazartesi", "1":
return time.Monday
case "tuesday", "tue", "sali", "salı", "2":
return time.Tuesday
case "wednesday", "wed", "carsamba", "çarşamba", "3":
return time.Wednesday
case "thursday", "thu", "persembe", "perşembe", "4":
return time.Thursday
case "friday", "fri", "cuma", "5":
return time.Friday
case "saturday", "sat", "cumartesi", "6":
return time.Saturday
default:
return fallback
}
}
File diff suppressed because it is too large Load Diff
+267 -172
View File
@@ -8,9 +8,9 @@ func productPerformanceSalesSQL() string {
SalesDate = CAST(I.InvoiceDate AS date),
I.InvoiceHeaderID,
I.InvoiceNumber,
ProductCode = LTRIM(RTRIM(I.ItemCode)),
ColorCode = LTRIM(RTRIM(ISNULL(I.ColorCode, ''))),
YakaKodu = LTRIM(RTRIM(ISNULL(I.ItemDim2Code, ''))),
ProductCode = UPPER(LTRIM(RTRIM(I.ItemCode))),
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(I.ColorCode, '')))),
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(I.ItemDim2Code, '')))),
ItemDescription = dbo.HG_Temizlik(ISNULL((
SELECT ItemDescription
FROM cdItemDesc WITH(NOLOCK)
@@ -18,76 +18,11 @@ func productPerformanceSalesSQL() string {
AND cdItemDesc.ItemCode = I.ItemCode
AND cdItemDesc.LangCode = 'TR'
), SPACE(0))),
UrunAnaGrubu = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdItemAttributeDesc WITH(NOLOCK)
WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode
AND cdItemAttributeDesc.AttributeTypeCode = 1
AND cdItemAttributeDesc.AttributeCode = (
SELECT TOP 1 AttributeCode
FROM prItemAttribute WITH(NOLOCK)
WHERE AttributeTypeCode = 1
AND prItemAttribute.ItemTypeCode = I.ItemTypeCode
AND prItemAttribute.ItemCode = I.ItemCode
)
AND cdItemAttributeDesc.LangCode = 'TR'
), SPACE(0))),
UrunAltGrubu = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdItemAttributeDesc WITH(NOLOCK)
WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode
AND cdItemAttributeDesc.AttributeTypeCode = 2
AND cdItemAttributeDesc.AttributeCode = (
SELECT TOP 1 AttributeCode
FROM prItemAttribute WITH(NOLOCK)
WHERE AttributeTypeCode = 2
AND prItemAttribute.ItemTypeCode = I.ItemTypeCode
AND prItemAttribute.ItemCode = I.ItemCode
)
AND cdItemAttributeDesc.LangCode = 'TR'
), SPACE(0))),
Kategori = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdItemAttributeDesc WITH(NOLOCK)
WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode
AND cdItemAttributeDesc.AttributeTypeCode = 44
AND cdItemAttributeDesc.AttributeCode = (
SELECT TOP 1 AttributeCode
FROM prItemAttribute WITH(NOLOCK)
WHERE AttributeTypeCode = 44
AND prItemAttribute.ItemTypeCode = I.ItemTypeCode
AND prItemAttribute.ItemCode = I.ItemCode
)
AND cdItemAttributeDesc.LangCode = 'TR'
), SPACE(0))),
UrunIlkGrubu = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdItemAttributeDesc WITH(NOLOCK)
WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode
AND cdItemAttributeDesc.AttributeTypeCode = 42
AND cdItemAttributeDesc.AttributeCode = (
SELECT TOP 1 AttributeCode
FROM prItemAttribute WITH(NOLOCK)
WHERE AttributeTypeCode = 42
AND prItemAttribute.ItemTypeCode = I.ItemTypeCode
AND prItemAttribute.ItemCode = I.ItemCode
)
AND cdItemAttributeDesc.LangCode = 'TR'
), SPACE(0))),
AskiliYan = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdItemAttributeDesc WITH(NOLOCK)
WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode
AND cdItemAttributeDesc.AttributeTypeCode = 45
AND cdItemAttributeDesc.AttributeCode = (
SELECT TOP 1 AttributeCode
FROM prItemAttribute WITH(NOLOCK)
WHERE AttributeTypeCode = 45
AND prItemAttribute.ItemTypeCode = I.ItemTypeCode
AND prItemAttribute.ItemCode = I.ItemCode
)
AND cdItemAttributeDesc.LangCode = 'TR'
), SPACE(0))),
UrunAnaGrubu = dbo.HG_Temizlik(ISNULL(PAF.ProductAtt01, SPACE(0))),
UrunAltGrubu = dbo.HG_Temizlik(ISNULL(PAF.ProductAtt02, SPACE(0))),
Kategori = dbo.HG_Temizlik(ISNULL(PAF.ProductAtt44, SPACE(0))),
UrunIlkGrubu = dbo.HG_Temizlik(ISNULL(PAF.ProductAtt42, SPACE(0))),
AskiliYan = dbo.HG_Temizlik(ISNULL(PAF.ProductAtt45, SPACE(0))),
ChannelCode = CASE
WHEN I.ProcessCode IN ('R') THEN dbo.HG_Temizlik(ISNULL((
SELECT OfficeDescription
@@ -141,16 +76,19 @@ func productPerformanceSalesSQL() string {
), SPACE(0)))
END,
Qty1 = ISNULL(I.Qty1, 0),
TLAmount = ISNULL(I.Doc_Amount, 0) * ISNULL(I.Loc_ExchangeRate, 0),
USDAmount = (ISNULL(I.Doc_Amount, 0) * ISNULL(I.Loc_ExchangeRate, 0)) / ISNULL((
SELECT TOP 1 Rate
FROM AllExchangeRates WITH(NOLOCK)
WHERE CurrencyCode = 'USD'
AND RelationCurrencyCode = 'TRY'
AND ExchangeTypeCode = 6
AND Rate > 1
ORDER BY ABS(DATEDIFF(DAY, AllExchangeRates.Date, I.InvoiceDate)) ASC
), 1),
TLAmount = CASE
WHEN Amount.DocCurrencyCode = 'TRY' THEN Amount.DocNetAmount
WHEN CurRate.Rate > 0 THEN Amount.DocNetAmount * CurRate.Rate
WHEN ISNULL(I.Loc_ExchangeRate, 0) > 0 THEN Amount.DocNetAmount * I.Loc_ExchangeRate
ELSE 0
END,
USDAmount = CASE
WHEN Amount.DocCurrencyCode = 'USD' THEN Amount.DocNetAmount
WHEN Amount.DocCurrencyCode = 'TRY' AND UsdRate.Rate > 0 THEN Amount.DocNetAmount / UsdRate.Rate
WHEN CurRate.Rate > 0 AND UsdRate.Rate > 0 THEN (Amount.DocNetAmount * CurRate.Rate) / UsdRate.Rate
WHEN ISNULL(I.Loc_ExchangeRate, 0) > 0 AND UsdRate.Rate > 0 THEN (Amount.DocNetAmount * I.Loc_ExchangeRate) / UsdRate.Rate
ELSE 0
END,
RN = ROW_NUMBER() OVER (
PARTITION BY I.InvoiceLineID
ORDER BY I.InvoiceDate DESC, I.InvoiceNumber
@@ -159,21 +97,46 @@ func productPerformanceSalesSQL() string {
LEFT OUTER JOIN CustomerAttributesFilter CAF WITH(NOLOCK)
ON CAF.CurrAccTypeCode = I.CurrAccTypeCode
AND CAF.CurrAccCode = I.CurrAccCode
LEFT OUTER JOIN ProductAttributesFilter PAF WITH(NOLOCK)
ON PAF.ItemCode = I.ItemCode
LEFT OUTER JOIN dbo.trInvoiceLineCurrency ILC WITH(NOLOCK)
ON ILC.InvoiceLineID = I.InvoiceLineID
AND UPPER(LTRIM(RTRIM(ILC.CurrencyCode))) = UPPER(LTRIM(RTRIM(ISNULL(NULLIF(LTRIM(RTRIM(I.Doc_CurrencyCode)), ''), 'TRY'))))
OUTER APPLY (
SELECT
DocCurrencyCode = UPPER(LTRIM(RTRIM(ISNULL(NULLIF(LTRIM(RTRIM(I.Doc_CurrencyCode)), ''), ISNULL(NULLIF(LTRIM(RTRIM(ILC.CurrencyCode)), ''), 'TRY'))))),
DocNetAmount = COALESCE(
NULLIF(ISNULL(ILC.NetAmount, 0), 0),
NULLIF(ISNULL(I.Doc_Amount, 0), 0),
NULLIF(ISNULL(I.Doc_Price, 0) * ISNULL(I.Qty1, 0), 0),
0
)
) Amount
OUTER APPLY (
SELECT TOP 1 Rate
FROM AllExchangeRates WITH(NOLOCK)
WHERE CurrencyCode = 'USD'
AND RelationCurrencyCode = 'TRY'
AND ExchangeTypeCode = 6
AND Rate > 0
AND Date <= CAST(I.InvoiceDate AS date)
ORDER BY Date DESC
) UsdRate
OUTER APPLY (
SELECT TOP 1 Rate
FROM AllExchangeRates WITH(NOLOCK)
WHERE CurrencyCode = Amount.DocCurrencyCode
AND RelationCurrencyCode = 'TRY'
AND ExchangeTypeCode = 6
AND Rate > 0
AND Date <= CAST(I.InvoiceDate AS date)
ORDER BY Date DESC
) CurRate
WHERE I.InvoiceDate >= @p1
AND I.InvoiceDate < DATEADD(DAY, 1, @p2)
AND I.ItemTypeCode = 1
AND (
LEN(LTRIM(RTRIM(I.ItemCode))) = 13
AND (
I.ItemCode LIKE 'S%'
OR I.ItemCode LIKE 'O%'
OR I.ItemCode LIKE 'N%'
OR I.ItemCode LIKE 'X%'
OR I.ItemCode LIKE 'I%'
OR I.ItemCode LIKE 'A%'
)
)
AND (@p3 = '' OR I.ItemCode LIKE @p3 + '%')
AND LEN(LTRIM(RTRIM(I.ItemCode))) = 13
AND (@p3 = '' OR UPPER(LTRIM(RTRIM(I.ItemCode))) LIKE @p3 + '%')
AND (
(I.CompanyCode = 1 AND I.ATAtt01 IN (1,2) AND I.ProcessCode IN ('WS','R'))
OR (I.CompanyCode = 4 AND I.ProcessCode = 'R')
@@ -196,11 +159,11 @@ SELECT
Kategori = MAX(Kategori),
Seri = '',
YasGrubu = '',
AskiliYan = MAX(AskiliYan),
AskiliYan = ISNULL(NULLIF(MAX(AskiliYan), '-'), ''),
UrunIlkGrubu = MAX(UrunIlkGrubu),
UrunAnaGrubu = MAX(UrunAnaGrubu),
UrunAltGrubu = MAX(UrunAltGrubu),
MarketKey = CONCAT(MAX(Kategori), '|', MAX(UrunIlkGrubu), '|', MAX(AskiliYan), '|', MAX(UrunAnaGrubu), '|', MAX(UrunAltGrubu), '|', ChannelCode),
MarketKey = CONCAT(MAX(Kategori), '|', MAX(UrunIlkGrubu), '|', ISNULL(NULLIF(MAX(AskiliYan), '-'), ''), '|', MAX(UrunAnaGrubu), '|', MAX(UrunAltGrubu), '|', ChannelCode),
ChannelCode,
CustomerCountry = ISNULL(NULLIF(CustomerCountry, ''), '-'),
CustomerSegment = ISNULL(NULLIF(CustomerSegment, ''), '-'),
@@ -234,9 +197,9 @@ func productPerformanceStockSQLUnused() string {
),
Raw AS (
SELECT
ProductCode = LTRIM(RTRIM(S.ItemCode)),
ColorCode = LTRIM(RTRIM(ISNULL(S.ColorCode, ''))),
YakaKodu = LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))),
ProductCode = UPPER(LTRIM(RTRIM(S.ItemCode))),
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(S.ColorCode, '')))),
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(S.ItemDim2Code, '')))),
MovementDate = CAST(S.DocumentDate AS date),
ProcessCode = CASE
WHEN LTRIM(RTRIM(ISNULL(S.ProcessCode, ''))) <> '' THEN LTRIM(RTRIM(S.ProcessCode))
@@ -249,18 +212,8 @@ Raw AS (
INNER JOIN ActiveWarehouses W
ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
WHERE S.ItemTypeCode = 1
AND (
LEN(LTRIM(RTRIM(S.ItemCode))) = 13
AND (
S.ItemCode LIKE 'S%'
OR S.ItemCode LIKE 'O%'
OR S.ItemCode LIKE 'N%'
OR S.ItemCode LIKE 'X%'
OR S.ItemCode LIKE 'I%'
OR S.ItemCode LIKE 'A%'
)
)
AND (@p3 = '' OR S.ItemCode LIKE @p3 + '%')
AND LEN(LTRIM(RTRIM(S.ItemCode))) = 13
AND (@p3 = '' OR UPPER(LTRIM(RTRIM(S.ItemCode))) LIKE @p3 + '%')
AND S.DocumentDate < DATEADD(DAY, 1, @p2)
GROUP BY
S.ItemCode,
@@ -382,37 +335,23 @@ ORDER BY StockDate, ProductCode, ColorCode, YakaKodu
func productPerformancePriceSQL() string {
return `
;WITH ProductCodes AS (
SELECT DISTINCT ItemCode
FROM cdItem WITH(NOLOCK)
WHERE ItemTypeCode = 1
AND LEN(LTRIM(RTRIM(ItemCode))) = 13
AND (@p1 = '' OR UPPER(LTRIM(RTRIM(ItemCode))) LIKE @p1 + '%')
UNION
SELECT DISTINCT ItemCode
FROM trPriceListLine WITH(NOLOCK)
WHERE ItemTypeCode = 1
AND (
LEN(LTRIM(RTRIM(ItemCode))) = 13
AND (
ItemCode LIKE 'S%'
OR ItemCode LIKE 'O%'
OR ItemCode LIKE 'N%'
OR ItemCode LIKE 'X%'
OR ItemCode LIKE 'I%'
OR ItemCode LIKE 'A%'
)
)
AND (@p1 = '' OR ItemCode LIKE @p1 + '%')
AND LEN(LTRIM(RTRIM(ItemCode))) = 13
AND (@p1 = '' OR UPPER(LTRIM(RTRIM(ItemCode))) LIKE @p1 + '%')
UNION
SELECT DISTINCT ItemCode
FROM prItemBasePrice WITH(NOLOCK)
WHERE ItemTypeCode = 1
AND (
LEN(LTRIM(RTRIM(ItemCode))) = 13
AND (
ItemCode LIKE 'S%'
OR ItemCode LIKE 'O%'
OR ItemCode LIKE 'N%'
OR ItemCode LIKE 'X%'
OR ItemCode LIKE 'I%'
OR ItemCode LIKE 'A%'
)
)
AND (@p1 = '' OR ItemCode LIKE @p1 + '%')
AND LEN(LTRIM(RTRIM(ItemCode))) = 13
AND (@p1 = '' OR UPPER(LTRIM(RTRIM(ItemCode))) LIKE @p1 + '%')
),
LatestPrice AS (
SELECT
@@ -459,12 +398,26 @@ Cost AS (
AND LTRIM(RTRIM(b.CurrencyCode)) = 'USD'
)
SELECT
pc.ItemCode,
UPPER(LTRIM(RTRIM(pc.ItemCode))) AS ItemCode,
ItemDescription = dbo.HG_Temizlik(ISNULL((
SELECT ItemDescription
FROM cdItemDesc WITH(NOLOCK)
WHERE cdItemDesc.ItemTypeCode = 1
AND cdItemDesc.ItemCode = pc.ItemCode
AND cdItemDesc.LangCode = 'TR'
), SPACE(0))),
Kategori = dbo.HG_Temizlik(ISNULL(paf.ProductAtt44, SPACE(0))),
AskiliYan = dbo.HG_Temizlik(ISNULL(paf.ProductAtt45, SPACE(0))),
UrunIlkGrubu = dbo.HG_Temizlik(ISNULL(paf.ProductAtt42, SPACE(0))),
UrunAnaGrubu = dbo.HG_Temizlik(ISNULL(paf.ProductAtt01, SPACE(0))),
UrunAltGrubu = dbo.HG_Temizlik(ISNULL(paf.ProductAtt02, SPACE(0))),
CostPriceUsd = ISNULL(c.CostPriceUsd, 0),
BasePriceUsd = ISNULL(bp.BasePriceUsd, 0),
BasePriceTry = ISNULL(bp.BasePriceTry, 0),
c.LastPricingDate
FROM ProductCodes pc
LEFT JOIN ProductAttributesFilter paf WITH(NOLOCK)
ON paf.ItemCode = pc.ItemCode
LEFT JOIN BasePrice bp ON bp.ItemCode = pc.ItemCode
LEFT JOIN Cost c ON c.ItemCode = pc.ItemCode AND c.rn = 1
`
@@ -479,6 +432,34 @@ WITH LatestStock AS (
WHERE stock_date <= $1
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
),
Stock90Start AS (
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
product_code, color_code, yaka_kodu, stock_qty
FROM mk_product_performance_stock_daily
WHERE stock_date <= $1::date - INTERVAL '89 days'
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
),
Stock180Start AS (
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
product_code, color_code, yaka_kodu, stock_qty
FROM mk_product_performance_stock_daily
WHERE stock_date <= $1::date - INTERVAL '179 days'
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
),
Stock365Start AS (
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
product_code, color_code, yaka_kodu, stock_qty
FROM mk_product_performance_stock_daily
WHERE stock_date <= $1::date - INTERVAL '359 days'
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
),
StockTotalStart AS (
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
product_code, color_code, yaka_kodu, stock_qty
FROM mk_product_performance_stock_daily
WHERE stock_date <= DATE '2022-01-01'
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
),
SalesAgg AS (
SELECT
product_code,
@@ -489,7 +470,7 @@ SalesAgg AS (
MAX(kategori) AS kategori,
MAX(seri) AS seri,
MAX(yas_grubu) AS yas_grubu,
MAX(askili_yan) AS askili_yan,
COALESCE(MAX(NULLIF(askili_yan, '-')), '') AS askili_yan,
MAX(urun_ilk_grubu) AS urun_ilk_grubu,
MAX(urun_ana_grubu) AS urun_ana_grubu,
MAX(urun_alt_grubu) AS urun_alt_grubu,
@@ -498,25 +479,37 @@ SalesAgg AS (
SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS sales_qty_180d,
SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days') AS sales_qty_365d,
SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '729 days') AS sales_qty_730d,
SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01') AS sales_qty_total,
SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '29 days') AS sales_usd_30d,
SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS sales_usd_90d,
SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS sales_usd_180d,
SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days') AS sales_usd_365d,
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS customer_count_90d,
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' AND COALESCE(sales_usd, 0) > 0) AS customer_count_90d,
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days' AND COALESCE(sales_usd, 0) > 0) AS customer_count_180d,
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days' AND COALESCE(sales_usd, 0) > 0) AS customer_count_365d,
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(sales_usd, 0) > 0) AS customer_count_total,
MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS first_sale_date_90d,
MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS first_sale_date_180d,
MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days') AS first_sale_date_365d,
MIN(sales_date) FILTER (WHERE sales_date >= DATE '2022-01-01') AS first_sale_date_total,
MAX(sales_date) AS last_sale_date,
MAX(last_ref_number) AS last_ref_number
FROM mk_product_performance_sales_daily
WHERE sales_date >= $1::date - INTERVAL '729 days'
WHERE sales_date >= DATE '2022-01-01'
AND sales_date <= $1::date
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
GROUP BY product_code, color_code, yaka_kodu, market_key
),
Scope AS (
SELECT
product_code, color_code, yaka_kodu, market_key,
item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu,
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d,
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d,
customer_count_90d, last_sale_date, last_ref_number
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total,
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total,
customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total,
first_sale_date_90d, first_sale_date_180d, first_sale_date_365d, first_sale_date_total,
last_sale_date, last_ref_number
FROM SalesAgg
UNION ALL
SELECT
@@ -529,7 +522,7 @@ Scope AS (
'' AS seri,
'' AS yas_grubu,
'' AS askili_yan,
'' AS urun_ilk_grubu,
COALESCE(pd.urun_ilk_grubu, '') AS urun_ilk_grubu,
'' AS urun_ana_grubu,
'' AS urun_alt_grubu,
0 AS sales_qty_30d,
@@ -537,15 +530,26 @@ Scope AS (
0 AS sales_qty_180d,
0 AS sales_qty_365d,
0 AS sales_qty_730d,
0 AS sales_qty_total,
0 AS sales_usd_30d,
0 AS sales_usd_90d,
0 AS sales_usd_180d,
0 AS sales_usd_365d,
0 AS sales_usd_total,
0 AS customer_count_90d,
0 AS customer_count_180d,
0 AS customer_count_365d,
0 AS customer_count_total,
NULL::date AS first_sale_date_90d,
NULL::date AS first_sale_date_180d,
NULL::date AS first_sale_date_365d,
NULL::date AS first_sale_date_total,
NULL::date AS last_sale_date,
'' AS last_ref_number
FROM LatestStock ls
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=ls.product_code
WHERE COALESCE(ls.stock_qty, 0) <> 0
AND upper(translate(btrim(COALESCE(pd.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
AND NOT EXISTS (
SELECT 1
FROM SalesAgg sa
@@ -554,6 +558,23 @@ Scope AS (
AND sa.yaka_kodu = ls.yaka_kodu
)
),
PriceDim AS (
SELECT
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
WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd > base_price_usd THEN base_price_usd
ELSE cost_price_usd
END AS cost_price_usd,
CASE
WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd > base_price_usd THEN cost_price_usd
ELSE base_price_usd
END AS base_price_usd,
base_price_try
FROM mk_product_performance_price_dim
),
Base AS (
SELECT
s.*,
@@ -563,6 +584,12 @@ Base AS (
COALESCE(pd.base_price_try, 0) AS base_price_try,
COALESCE(s.sales_qty_90d, 0) / 90.0 AS avg_daily_sales_90d,
COALESCE(s.sales_qty_180d, 0) / 180.0 AS avg_daily_sales_180d,
COALESCE(s.sales_qty_365d, 0) / 360.0 AS avg_daily_sales_365d,
COALESCE(s.sales_qty_total, 0) / GREATEST(1, ($1::date - DATE '2022-01-01') + 1) AS avg_daily_sales_total,
(COALESCE(s90.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_90d,
(COALESCE(s180.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_180d,
(COALESCE(s365.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_365d,
(COALESCE(stotal.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_total,
CASE WHEN COALESCE(s.sales_qty_90d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_90d, 0) / NULLIF(s.sales_qty_90d, 0) END AS avg_price_usd_90d,
CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0) END AS avg_price_usd_180d,
COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_90d,
@@ -579,22 +606,68 @@ Base AS (
CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0)) - COALESCE(pd.base_price_usd, 0) END AS unit_profit_base_180d
FROM Scope s
LEFT JOIN LatestStock ls ON ls.product_code=s.product_code AND ls.color_code=s.color_code AND ls.yaka_kodu=s.yaka_kodu
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=s.product_code
LEFT JOIN PriceDim pd ON pd.product_code=s.product_code
LEFT JOIN LATERAL (
SELECT x.stock_qty
FROM mk_product_performance_stock_daily x
WHERE x.product_code=s.product_code
AND x.color_code=s.color_code
AND x.yaka_kodu=s.yaka_kodu
AND x.stock_date <= COALESCE(s.first_sale_date_90d, $1::date - INTERVAL '89 days')
ORDER BY x.stock_date DESC
LIMIT 1
) s90 ON TRUE
LEFT JOIN LATERAL (
SELECT x.stock_qty
FROM mk_product_performance_stock_daily x
WHERE x.product_code=s.product_code
AND x.color_code=s.color_code
AND x.yaka_kodu=s.yaka_kodu
AND x.stock_date <= COALESCE(s.first_sale_date_180d, $1::date - INTERVAL '179 days')
ORDER BY x.stock_date DESC
LIMIT 1
) s180 ON TRUE
LEFT JOIN LATERAL (
SELECT x.stock_qty
FROM mk_product_performance_stock_daily x
WHERE x.product_code=s.product_code
AND x.color_code=s.color_code
AND x.yaka_kodu=s.yaka_kodu
AND x.stock_date <= COALESCE(s.first_sale_date_365d, $1::date - INTERVAL '359 days')
ORDER BY x.stock_date DESC
LIMIT 1
) s365 ON TRUE
LEFT JOIN LATERAL (
SELECT x.stock_qty
FROM mk_product_performance_stock_daily x
WHERE x.product_code=s.product_code
AND x.color_code=s.color_code
AND x.yaka_kodu=s.yaka_kodu
AND x.stock_date <= COALESCE(s.first_sale_date_total, DATE '2022-01-01')
ORDER BY x.stock_date DESC
LIMIT 1
) stotal ON TRUE
WHERE upper(translate(btrim(COALESCE(NULLIF(s.urun_ilk_grubu,''), pd.urun_ilk_grubu, '')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
),
Spread AS (
SELECT
product_code,
color_code,
yaka_kodu,
COUNT(DISTINCT market_key) AS market_count_90d
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_90d, 0) > 0) AS market_count_90d,
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_180d, 0) > 0) AS market_count_180d,
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_365d, 0) > 0) AS market_count_365d,
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_total, 0) > 0) AS market_count_total
FROM SalesAgg
WHERE COALESCE(sales_qty_90d, 0) > 0
GROUP BY product_code, color_code, yaka_kodu
),
Market AS (
SELECT
market_key,
AVG(NULLIF(sales_qty_90d, 0)) AS market_avg_sales_qty_90d,
AVG(NULLIF(sales_qty_180d, 0)) AS market_avg_sales_qty_180d,
AVG(NULLIF(sales_qty_365d, 0)) AS market_avg_sales_qty_365d,
AVG(NULLIF(sales_qty_total, 0)) AS market_avg_sales_qty_total,
AVG(NULLIF(avg_price_usd_90d, 0)) AS market_avg_price_usd_90d,
AVG(NULLIF(gross_margin_90d, 0)) AS market_avg_margin_90d
FROM Base
@@ -604,9 +677,21 @@ Scored AS (
SELECT
b.*,
COALESCE(sp.market_count_90d, 0) AS market_count_90d,
CASE WHEN b.avg_daily_sales_90d = 0 THEN 0 ELSE b.stock_qty / NULLIF(b.avg_daily_sales_90d, 0) END AS stock_days_90d,
CASE WHEN b.avg_daily_sales_180d = 0 THEN 0 ELSE b.stock_qty / NULLIF(b.avg_daily_sales_180d, 0) END AS stock_days_180d,
COALESCE(sp.market_count_180d, 0) AS market_count_180d,
COALESCE(sp.market_count_365d, 0) AS market_count_365d,
COALESCE(sp.market_count_total, 0) AS market_count_total,
CASE WHEN b.avg_daily_sales_90d = 0 THEN 0 ELSE b.avg_stock_90d / NULLIF(b.avg_daily_sales_90d, 0) END AS stock_days_90d,
CASE WHEN b.avg_daily_sales_180d = 0 THEN 0 ELSE b.avg_stock_180d / NULLIF(b.avg_daily_sales_180d, 0) END AS stock_days_180d,
CASE WHEN b.avg_daily_sales_365d = 0 THEN 0 ELSE b.avg_stock_365d / NULLIF(b.avg_daily_sales_365d, 0) END AS stock_days_365d,
CASE WHEN b.avg_daily_sales_total = 0 THEN 0 ELSE b.avg_stock_total / NULLIF(b.avg_daily_sales_total, 0) END AS stock_days_total,
CASE WHEN b.avg_stock_90d = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(b.avg_stock_90d, 0) * 4.0 END AS stock_turnover_90d,
CASE WHEN b.avg_stock_180d = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(b.avg_stock_180d, 0) * 2.0 END AS stock_turnover_180d,
CASE WHEN b.avg_stock_365d = 0 THEN 0 ELSE b.sales_qty_365d / NULLIF(b.avg_stock_365d, 0) END AS stock_turnover_365d,
CASE WHEN b.avg_stock_total = 0 THEN 0 ELSE b.sales_qty_total / NULLIF(b.avg_stock_total, 0) * 360.0 / GREATEST(1, ($1::date - DATE '2022-01-01') + 1) END AS stock_turnover_total,
CASE WHEN COALESCE(m.market_avg_sales_qty_90d, 0) = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(m.market_avg_sales_qty_90d, 0) END AS sales_index_90d,
CASE WHEN COALESCE(m.market_avg_sales_qty_180d, 0) = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(m.market_avg_sales_qty_180d, 0) END AS sales_index_180d,
CASE WHEN COALESCE(m.market_avg_sales_qty_365d, 0) = 0 THEN 0 ELSE b.sales_qty_365d / NULLIF(m.market_avg_sales_qty_365d, 0) END AS sales_index_365d,
CASE WHEN COALESCE(m.market_avg_sales_qty_total, 0) = 0 THEN 0 ELSE b.sales_qty_total / NULLIF(m.market_avg_sales_qty_total, 0) END AS sales_index_total,
CASE WHEN COALESCE(m.market_avg_price_usd_90d, 0) = 0 THEN 0 ELSE b.avg_price_usd_90d / NULLIF(m.market_avg_price_usd_90d, 0) END AS price_index_90d,
CASE WHEN COALESCE(m.market_avg_margin_90d, 0) = 0 THEN 0 ELSE b.gross_margin_90d / NULLIF(m.market_avg_margin_90d, 0) END AS margin_index_90d
FROM Base b
@@ -616,33 +701,43 @@ Scored AS (
INSERT INTO mk_product_performance_kpi_daily (
kpi_date, product_code, color_code, yaka_kodu, item_description,
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty,
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d,
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, avg_daily_sales_90d, avg_daily_sales_180d,
stock_days_90d, stock_days_180d, avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try,
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total,
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total, avg_daily_sales_90d, avg_daily_sales_180d, avg_daily_sales_365d, avg_daily_sales_total,
avg_stock_90d, avg_stock_180d, avg_stock_365d, avg_stock_total,
stock_days_90d, stock_days_180d, stock_days_365d, stock_days_total, stock_turnover_90d, stock_turnover_180d, stock_turnover_365d, stock_turnover_total,
avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try,
gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d,
unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d, market_count_90d, customer_count_90d,
sales_index_90d, price_index_90d, margin_index_90d, performance_score,
unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d,
market_count_90d, market_count_180d, market_count_365d, market_count_total,
customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total,
sales_index_90d, sales_index_180d, sales_index_365d, sales_index_total, price_index_90d, margin_index_90d, performance_score,
performance_bucket, recommendation, last_sale_date, last_ref_number, updated_at
)
SELECT
$1::date,
product_code, color_code, yaka_kodu, item_description,
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty,
COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_180d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0),
COALESCE(sales_usd_30d, 0), COALESCE(sales_usd_90d, 0), COALESCE(sales_usd_180d, 0), COALESCE(sales_usd_365d, 0), COALESCE(avg_daily_sales_90d, 0), COALESCE(avg_daily_sales_180d, 0),
COALESCE(stock_days_90d, 0), COALESCE(stock_days_180d, 0), COALESCE(avg_price_usd_90d, 0), COALESCE(avg_price_usd_180d, 0), COALESCE(cost_price_usd, 0), COALESCE(base_price_usd, 0), COALESCE(base_price_try, 0),
COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_180d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0), COALESCE(sales_qty_total, 0),
COALESCE(sales_usd_30d, 0), COALESCE(sales_usd_90d, 0), COALESCE(sales_usd_180d, 0), COALESCE(sales_usd_365d, 0), COALESCE(sales_usd_total, 0), COALESCE(avg_daily_sales_90d, 0), COALESCE(avg_daily_sales_180d, 0), COALESCE(avg_daily_sales_365d, 0), COALESCE(avg_daily_sales_total, 0),
COALESCE(avg_stock_90d, 0), COALESCE(avg_stock_180d, 0), COALESCE(avg_stock_365d, 0), COALESCE(avg_stock_total, 0),
COALESCE(stock_days_90d, 0), COALESCE(stock_days_180d, 0), COALESCE(stock_days_365d, 0), COALESCE(stock_days_total, 0), COALESCE(stock_turnover_90d, 0), COALESCE(stock_turnover_180d, 0), COALESCE(stock_turnover_365d, 0), COALESCE(stock_turnover_total, 0),
COALESCE(avg_price_usd_90d, 0), COALESCE(avg_price_usd_180d, 0), COALESCE(cost_price_usd, 0), COALESCE(base_price_usd, 0), COALESCE(base_price_try, 0),
COALESCE(gross_profit_usd_90d, 0), COALESCE(gross_profit_usd_180d, 0), COALESCE(gross_margin_90d, 0), COALESCE(gross_margin_180d, 0),
COALESCE(unit_profit_cost_90d, 0), COALESCE(unit_profit_cost_180d, 0), COALESCE(unit_profit_base_90d, 0), COALESCE(unit_profit_base_180d, 0), COALESCE(market_count_90d, 0), COALESCE(customer_count_90d, 0),
COALESCE(sales_index_90d, 0), COALESCE(price_index_90d, 0), COALESCE(margin_index_90d, 0),
COALESCE(unit_profit_cost_90d, 0), COALESCE(unit_profit_cost_180d, 0), COALESCE(unit_profit_base_90d, 0), COALESCE(unit_profit_base_180d, 0),
COALESCE(market_count_90d, 0), COALESCE(market_count_180d, 0), COALESCE(market_count_365d, 0), COALESCE(market_count_total, 0),
COALESCE(customer_count_90d, 0), COALESCE(customer_count_180d, 0), COALESCE(customer_count_365d, 0), COALESCE(customer_count_total, 0),
COALESCE(sales_index_90d, 0), COALESCE(sales_index_180d, 0), COALESCE(sales_index_365d, 0), COALESCE(sales_index_total, 0), COALESCE(price_index_90d, 0), COALESCE(margin_index_90d, 0),
CASE WHEN COALESCE(sales_usd_90d, 0) <= 0 THEN 1 ELSE
ROUND(
LEAST(35, COALESCE(sales_index_90d, 0) * 18)
+ LEAST(25, GREATEST(COALESCE(gross_margin_90d, 0), 0) * 50)
+ LEAST(20, COALESCE(customer_count_90d, 0) * 2)
+ LEAST(10, COALESCE(market_count_90d, 0) * 3)
+ CASE WHEN COALESCE(stock_days_90d, 0) BETWEEN 10 AND 90 THEN 10 WHEN COALESCE(stock_days_90d, 0) > 180 THEN -10 ELSE 0 END
, 4) AS performance_score,
LEAST(30, GREATEST(COALESCE(gross_margin_90d, 0), 0) / 0.45 * 30)
+ LEAST(20, COALESCE(stock_turnover_90d, 0) / 4.0 * 20)
+ LEAST(20, COALESCE(sales_usd_90d, 0) / 25000.0 * 20)
+ LEAST(5, COALESCE(market_count_90d, 0) / 3.0 * 5)
+ LEAST(25, COALESCE(customer_count_90d, 0) / 8.0 * 25)
, 4)
END AS performance_score,
CASE
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'YILDIZ_URUN'
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.45 THEN 'YILDIZ_URUN'
WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'STOKSUZ_TALEP'
WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'STOK_RISKI'
WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'FIYAT_BASKISI'
@@ -650,12 +745,12 @@ SELECT
ELSE 'TAKIP'
END AS performance_bucket,
CASE
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'Piyasa üstü satış ve iyi marj: stok ve fiyat gücü takip edilmeli.'
WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: üretim/satın alma planına alınmalı.'
WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'Stok yüksek, satış piyasa altı: kampanya veya fiyat kontrolü gerekli.'
WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa üstünde ve satış zayıf: fiyat revizyonu değerlendirilmeli.'
WHEN COALESCE(price_index_90d,0) < 0.90 AND COALESCE(sales_index_90d,0) > 1.00 THEN 'Satış güçlü, fiyat piyasa altı: taban fiyat artışı değerlendirilebilir.'
ELSE 'Düzenli takip.'
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.45 THEN 'Piyasa ustu satis ve iyi marj: stok ve fiyat gucu takip edilmeli.'
WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: uretim/satin alma planina alinmali.'
WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'Stok yuksek, satis piyasa alti: kampanya veya fiyat kontrolu gerekli.'
WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa ustunde ve satis zayif: fiyat revizyonu degerlendirilmeli.'
WHEN COALESCE(price_index_90d,0) < 0.90 AND COALESCE(sales_index_90d,0) > 1.00 THEN 'Satis guclu, fiyat piyasa alti: taban fiyat artisi degerlendirilebilir.'
ELSE 'Duzenli takip.'
END AS recommendation,
last_sale_date,
COALESCE(last_ref_number, ''),
+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)
}
type ArgeBaggiDepotEntryTotals struct {
Ceket float64
Pantolon float64
Yelek float64
}
// GetArgeBaggiDepotEntryTotals returns operation 985/986/987 quantities keyed
// by production work order. The requested work orders are queried in one batch
// to avoid an ArgeBAGGI round trip for every list row.
func GetArgeBaggiDepotEntryTotals(ctx context.Context, argeDB *sql.DB, workOrders []string) (map[string]ArgeBaggiDepotEntryTotals, error) {
out := make(map[string]ArgeBaggiDepotEntryTotals)
if argeDB == nil || len(workOrders) == 0 {
return out, nil
}
seen := make(map[string]struct{}, len(workOrders))
values := make([]string, 0, len(workOrders))
for _, raw := range workOrders {
value := strings.TrimSpace(raw)
if value == "" || value == "0" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
values = append(values, value)
}
if len(values) == 0 {
return out, nil
}
placeholders := make([]string, 0, len(values))
args := make([]any, 0, len(values))
for i, value := range values {
placeholders = append(placeholders, fmt.Sprintf("@p%d", i+1))
args = append(args, value)
}
sqlText := fmt.Sprintf(`
SELECT
LTRIM(RTRIM(CONVERT(VARCHAR(64), MN.model_adi))) AS IsEmriNo,
SUM(CASE WHEN O.oper_num = 985 THEN CONVERT(float, B.adet) ELSE 0 END) AS CeketDepoGiris,
SUM(CASE WHEN O.oper_num = 986 THEN CONVERT(float, B.adet) ELSE 0 END) AS PantolonDepoGiris,
SUM(CASE WHEN O.oper_num = 987 THEN CONVERT(float, B.adet) ELSE 0 END) AS YelekDepoGiris
FROM dbo.is_acma_bar B
INNER JOIN dbo.is_acma I ON I.is_num = B.is_num
INNER JOIN dbo.model_def MD ON I.imdef_num = MD.mdef_num
INNER JOIN dbo.oper_urun_no O ON B.oper_num = O.oper_num
INNER JOIN dbo.model_num MN ON I.model_num = MN.model_num
INNER JOIN dbo.bant_no BN ON I.bant_num = BN.bant_no
INNER JOIN dbo.model_oper MO ON O.oper_num = MO.oper_num AND MD.mdef_num = MO.model_num
INNER JOIN dbo.model_oper_tkp_def TD ON MO.tkp_cik = TD.tkp_cik
WHERE B.tarih >= '2025-01-01'
AND TD.tkp_cik IN (1,2,3,4,5)
AND O.oper_num IN (985,986,987)
AND LTRIM(RTRIM(CONVERT(VARCHAR(64), MN.model_adi))) IN (%s)
GROUP BY LTRIM(RTRIM(CONVERT(VARCHAR(64), MN.model_adi)))
`, strings.Join(placeholders, ","))
rows, err := argeDB.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var workOrder string
var totals ArgeBaggiDepotEntryTotals
if err := rows.Scan(&workOrder, &totals.Ceket, &totals.Pantolon, &totals.Yelek); err != nil {
return nil, err
}
out[strings.TrimSpace(workOrder)] = totals
}
return out, rows.Err()
}
func GetProductionHasCostProducts(
ctx context.Context,
uretimDB *sql.DB,
@@ -816,12 +892,22 @@ WITH SonOnMaliyet AS (
FROM dbo.spUrtOnMLMas M
WHERE LTRIM(RTRIM(M.UrunKodu)) <> ''
),
SonSiparis AS (
SonSiparisSirali AS (
SELECT
LTRIM(RTRIM(sMModelKodu)) AS UrunKodu,
MAX(dteIslemTarihi) AS SonSiparisTarihi
FROM dbo.spUrtSiparisDet
GROUP BY LTRIM(RTRIM(sMModelKodu))
LTRIM(RTRIM(SD.sMModelKodu)) AS UrunKodu,
SD.dteIslemTarihi AS SonSiparisTarihi,
SM.nUrtSiparisNo,
ROW_NUMBER() OVER (
PARTITION BY LTRIM(RTRIM(SD.sMModelKodu))
ORDER BY SD.dteIslemTarihi DESC, SD.nUrtSiparisID DESC
) AS rn
FROM dbo.spUrtSiparisDet SD
INNER JOIN dbo.spUrtSiparis SM ON SM.nUrtSiparisID = SD.nUrtSiparisID
),
SonSiparis AS (
SELECT UrunKodu, SonSiparisTarihi, nUrtSiparisNo
FROM SonSiparisSirali
WHERE rn = 1
)
SELECT
CASE
@@ -831,6 +917,7 @@ SELECT
THEN N'FASON ICIN URETILEN'
ELSE N'Tanimsiz'
END AS UretimSekli,
RTRIM(CONVERT(VARCHAR(32), ISNULL(SS.nUrtSiparisNo, 0))) AS nUrtSiparisNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(OM.nOnMLNo, 0))) AS nOnMLNo,
LTRIM(RTRIM(ISNULL(OM.UrunKodu, ''))) AS UrunKodu,
ISNULL(OM.UrunAdi, '') AS UrunAdi,
+23 -23
View File
@@ -3,7 +3,7 @@ package queries
// GetProductStockQuery:
// Urun kodu bazli, STOCK/PICK/RESERVE/DISP ayrik CTE ile optimize sorgu.
const GetProductStockQuery = `
DECLARE @ProductCode NVARCHAR(50) = @p1;
DECLARE @ProductCode NVARCHAR(50) = UPPER(LTRIM(RTRIM(@p1)));
;WITH STOCK AS
(
@@ -14,7 +14,7 @@ DECLARE @ProductCode NVARCHAR(50) = @p1;
StoreCode,
WarehouseCode,
ItemTypeCode,
ItemCode,
UPPER(LTRIM(RTRIM(ItemCode))) AS ItemCode,
ColorCode,
ItemDim1Code,
ItemDim2Code,
@@ -22,12 +22,12 @@ DECLARE @ProductCode NVARCHAR(50) = @p1;
SUM(In_Qty1 - Out_Qty1) AS InventoryQty1
FROM trStock
WHERE ItemTypeCode = 1
AND ItemCode = @ProductCode
AND LEN(ItemCode) = 13
AND UPPER(LTRIM(RTRIM(ItemCode))) = @ProductCode
AND LEN(LTRIM(RTRIM(ItemCode))) = 13
AND LEN(@ProductCode) = 13
GROUP BY
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode,
ItemTypeCode, UPPER(LTRIM(RTRIM(ItemCode))), ColorCode,
ItemDim1Code, ItemDim2Code, ItemDim3Code
),
@@ -35,17 +35,17 @@ PICK AS
(
SELECT
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode,
ItemTypeCode, UPPER(LTRIM(RTRIM(ItemCode))) AS ItemCode, ColorCode,
ItemDim1Code, ItemDim2Code, ItemDim3Code,
SUM(Qty1) AS PickingQty1
FROM PickingStates
WHERE ItemTypeCode = 1
AND ItemCode = @ProductCode
AND LEN(ItemCode) = 13
AND UPPER(LTRIM(RTRIM(ItemCode))) = @ProductCode
AND LEN(LTRIM(RTRIM(ItemCode))) = 13
AND LEN(@ProductCode) = 13
GROUP BY
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode,
ItemTypeCode, UPPER(LTRIM(RTRIM(ItemCode))), ColorCode,
ItemDim1Code, ItemDim2Code, ItemDim3Code
),
@@ -53,17 +53,17 @@ RESERVE AS
(
SELECT
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode,
ItemTypeCode, UPPER(LTRIM(RTRIM(ItemCode))) AS ItemCode, ColorCode,
ItemDim1Code, ItemDim2Code, ItemDim3Code,
SUM(Qty1) AS ReserveQty1
FROM ReserveStates
WHERE ItemTypeCode = 1
AND ItemCode = @ProductCode
AND LEN(ItemCode) = 13
AND UPPER(LTRIM(RTRIM(ItemCode))) = @ProductCode
AND LEN(LTRIM(RTRIM(ItemCode))) = 13
AND LEN(@ProductCode) = 13
GROUP BY
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode,
ItemTypeCode, UPPER(LTRIM(RTRIM(ItemCode))), ColorCode,
ItemDim1Code, ItemDim2Code, ItemDim3Code
),
@@ -71,17 +71,17 @@ DISP AS
(
SELECT
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode,
ItemTypeCode, UPPER(LTRIM(RTRIM(ItemCode))) AS ItemCode, ColorCode,
ItemDim1Code, ItemDim2Code, ItemDim3Code,
SUM(Qty1) AS DispOrderQty1
FROM DispOrderStates
WHERE ItemTypeCode = 1
AND ItemCode = @ProductCode
AND LEN(ItemCode) = 13
AND UPPER(LTRIM(RTRIM(ItemCode))) = @ProductCode
AND LEN(LTRIM(RTRIM(ItemCode))) = 13
AND LEN(@ProductCode) = 13
GROUP BY
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode,
ItemTypeCode, UPPER(LTRIM(RTRIM(ItemCode))), ColorCode,
ItemDim1Code, ItemDim2Code, ItemDim3Code
)
@@ -185,14 +185,14 @@ AND DP.ItemDim2Code=S.ItemDim2Code
AND DP.ItemDim3Code=S.ItemDim3Code
JOIN cdItem
ON S.ItemCode = cdItem.ItemCode
ON UPPER(LTRIM(RTRIM(S.ItemCode))) = UPPER(LTRIM(RTRIM(cdItem.ItemCode)))
AND S.ItemTypeCode = cdItem.ItemTypeCode
LEFT JOIN cdUnitOfMeasure
ON cdItem.UnitOfMeasureCode1 = cdUnitOfMeasure.UnitOfMeasureCode
LEFT JOIN ProductFilterWithDescription('TR') P
ON P.ProductCode = S.ItemCode
ON UPPER(LTRIM(RTRIM(P.ProductCode))) = UPPER(LTRIM(RTRIM(S.ItemCode)))
LEFT JOIN bsItemTypeDesc
ON bsItemTypeDesc.ItemTypeCode = S.ItemTypeCode
@@ -214,14 +214,14 @@ LEFT JOIN (
ROW_NUMBER() OVER (PARTITION BY ItemCode, ItemTypeCode ORDER BY PriceDate DESC) AS RowNum
FROM prItemBasePrice
) prFilteredBasePrice
ON prFilteredBasePrice.ItemCode = S.ItemCode
ON UPPER(LTRIM(RTRIM(prFilteredBasePrice.ItemCode))) = UPPER(LTRIM(RTRIM(S.ItemCode)))
AND prFilteredBasePrice.ItemTypeCode = S.ItemTypeCode
AND prFilteredBasePrice.RowNum = 1
WHERE
S.ItemTypeCode IN (1)
AND S.ItemCode = @ProductCode
AND LEN(S.ItemCode) = 13
AND UPPER(LTRIM(RTRIM(S.ItemCode))) = @ProductCode
AND LEN(LTRIM(RTRIM(S.ItemCode))) = 13
AND LEN(@ProductCode) = 13
AND (
S.InventoryQty1
@@ -234,6 +234,6 @@ WHERE
(
'1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28',
'1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3',
'1-0-33','101','1-014','1-0-49','1-0-36'
'1-0-33','101','1-014','1-0-49','1-0-36','1-0-4','1-0-29'
);
`
+45 -45
View File
@@ -28,7 +28,7 @@ IF OBJECT_ID('dbo.ProductFilterTRCache','U') IS NOT NULL
BEGIN
INSERT INTO #AttrBase (ProductCode, Kategori, UrunAnaGrubu, UrunAltGrubu, UrunIcerigi, Fit, DropVal)
SELECT
ProductCode,
UPPER(LTRIM(RTRIM(ProductCode))) AS ProductCode,
Kategori = LTRIM(RTRIM(ProductAtt44Desc)),
UrunAnaGrubu = LTRIM(RTRIM(ProductAtt01Desc)),
UrunAltGrubu = LTRIM(RTRIM(ProductAtt02Desc)),
@@ -48,7 +48,7 @@ ELSE
BEGIN
INSERT INTO #AttrBase (ProductCode, Kategori, UrunAnaGrubu, UrunAltGrubu, UrunIcerigi, Fit, DropVal)
SELECT
ProductCode,
UPPER(LTRIM(RTRIM(ProductCode))) AS ProductCode,
Kategori = LTRIM(RTRIM(ProductAtt44Desc)),
UrunAnaGrubu = LTRIM(RTRIM(ProductAtt01Desc)),
UrunAltGrubu = LTRIM(RTRIM(ProductAtt02Desc)),
@@ -101,40 +101,40 @@ CREATE NONCLUSTERED INDEX IX_AttrBase_Filter ON #AttrBase(Kategori, UrunAnaGrubu
FROM
(
SELECT
P.ItemCode, P.ColorCode, P.ItemDim1Code, P.ItemDim2Code,
UPPER(LTRIM(RTRIM(P.ItemCode))) AS ItemCode, P.ColorCode, P.ItemDim1Code, P.ItemDim2Code,
P.Qty1 AS PickingQty1, 0 AS ReserveQty1, 0 AS DispOrderQty1, 0 AS InventoryQty1
FROM PickingStates P
INNER JOIN #AttrBase AB ON AB.ProductCode = P.ItemCode
INNER JOIN #AttrBase AB ON AB.ProductCode = UPPER(LTRIM(RTRIM(P.ItemCode)))
WHERE P.ItemTypeCode = 1
AND LEN(P.ItemCode) = 13
AND LEN(LTRIM(RTRIM(P.ItemCode))) = 13
UNION ALL
SELECT
R.ItemCode, R.ColorCode, R.ItemDim1Code, R.ItemDim2Code,
UPPER(LTRIM(RTRIM(R.ItemCode))) AS ItemCode, R.ColorCode, R.ItemDim1Code, R.ItemDim2Code,
0, R.Qty1, 0, 0
FROM ReserveStates R
INNER JOIN #AttrBase AB ON AB.ProductCode = R.ItemCode
INNER JOIN #AttrBase AB ON AB.ProductCode = UPPER(LTRIM(RTRIM(R.ItemCode)))
WHERE R.ItemTypeCode = 1
AND LEN(R.ItemCode) = 13
AND LEN(LTRIM(RTRIM(R.ItemCode))) = 13
UNION ALL
SELECT
D.ItemCode, D.ColorCode, D.ItemDim1Code, D.ItemDim2Code,
UPPER(LTRIM(RTRIM(D.ItemCode))) AS ItemCode, D.ColorCode, D.ItemDim1Code, D.ItemDim2Code,
0, 0, D.Qty1, 0
FROM DispOrderStates D
INNER JOIN #AttrBase AB ON AB.ProductCode = D.ItemCode
INNER JOIN #AttrBase AB ON AB.ProductCode = UPPER(LTRIM(RTRIM(D.ItemCode)))
WHERE D.ItemTypeCode = 1
AND LEN(D.ItemCode) = 13
AND LEN(LTRIM(RTRIM(D.ItemCode))) = 13
UNION ALL
SELECT
T.ItemCode, T.ColorCode, T.ItemDim1Code, T.ItemDim2Code,
UPPER(LTRIM(RTRIM(T.ItemCode))) AS ItemCode, T.ColorCode, T.ItemDim1Code, T.ItemDim2Code,
0, 0, 0, SUM(T.In_Qty1 - T.Out_Qty1)
FROM trStock T
INNER JOIN #AttrBase AB ON AB.ProductCode = T.ItemCode
INNER JOIN #AttrBase AB ON AB.ProductCode = UPPER(LTRIM(RTRIM(T.ItemCode)))
WHERE T.ItemTypeCode = 1
AND LEN(T.ItemCode) = 13
GROUP BY T.ItemCode, T.ColorCode, T.ItemDim1Code, T.ItemDim2Code
AND LEN(LTRIM(RTRIM(T.ItemCode))) = 13
GROUP BY UPPER(LTRIM(RTRIM(T.ItemCode))), T.ColorCode, T.ItemDim1Code, T.ItemDim2Code
) X
GROUP BY X.ItemCode, X.ColorCode, X.ItemDim1Code, X.ItemDim2Code
),
@@ -180,7 +180,7 @@ SELECT 'renk', X.FieldValue
FROM (
SELECT DISTINCT FieldValue = CASE WHEN A.RenkAciklama <> '' THEN A.RenkAciklama ELSE A.Renk END
FROM Avail A
INNER JOIN #AttrBase AB ON AB.ProductCode = A.ItemCode
INNER JOIN #AttrBase AB ON AB.ProductCode = UPPER(LTRIM(RTRIM(A.ItemCode)))
WHERE (CASE WHEN A.RenkAciklama <> '' THEN A.RenkAciklama ELSE A.Renk END) <> ''
AND (@Kategori IS NULL OR AB.Kategori = @Kategori)
AND (@UrunAnaGrubu IS NULL OR AB.UrunAnaGrubu = @UrunAnaGrubu)
@@ -196,7 +196,7 @@ SELECT 'renk2', X.FieldValue
FROM (
SELECT DISTINCT FieldValue = A.Renk2
FROM Avail A
INNER JOIN #AttrBase AB ON AB.ProductCode = A.ItemCode
INNER JOIN #AttrBase AB ON AB.ProductCode = UPPER(LTRIM(RTRIM(A.ItemCode)))
WHERE A.Renk2 <> ''
AND (@Kategori IS NULL OR AB.Kategori = @Kategori)
AND (@UrunAnaGrubu IS NULL OR AB.UrunAnaGrubu = @UrunAnaGrubu)
@@ -245,7 +245,7 @@ SELECT 'beden', X.FieldValue
FROM (
SELECT DISTINCT FieldValue = A.Beden
FROM Avail A
INNER JOIN #AttrBase AB ON AB.ProductCode = A.ItemCode
INNER JOIN #AttrBase AB ON AB.ProductCode = UPPER(LTRIM(RTRIM(A.ItemCode)))
WHERE A.Beden <> ''
AND (@Kategori IS NULL OR AB.Kategori = @Kategori)
AND (@UrunAnaGrubu IS NULL OR AB.UrunAnaGrubu = @UrunAnaGrubu)
@@ -346,7 +346,7 @@ BEGIN
ProductAtt46Desc
)
SELECT
C.ProductCode,
UPPER(LTRIM(RTRIM(C.ProductCode))) AS ProductCode,
C.ProductDescription,
C.ProductAtt01Desc,
C.ProductAtt02Desc,
@@ -379,7 +379,7 @@ BEGIN
ISNULL(PF.ProductAtt45Desc, '') AS ProductAtt45Desc,
ISNULL(PF.ProductAtt46Desc, '') AS ProductAtt46Desc
FROM dbo.ProductFilterTRCache C
LEFT JOIN ProductFilterWithDescription('TR') PF ON PF.ProductCode = C.ProductCode
LEFT JOIN ProductFilterWithDescription('TR') PF ON UPPER(LTRIM(RTRIM(PF.ProductCode))) = UPPER(LTRIM(RTRIM(C.ProductCode)))
WHERE LEN(C.ProductCode) = 13
AND (@Kategori IS NULL OR C.ProductAtt44Desc = @Kategori)
AND (@UrunAnaGrubu IS NULL OR C.ProductAtt01Desc = @UrunAnaGrubu)
@@ -426,7 +426,7 @@ BEGIN
ProductAtt46Desc
)
SELECT
ProductCode,
UPPER(LTRIM(RTRIM(ProductCode))) AS ProductCode,
ProductDescription,
ProductAtt01Desc,
ProductAtt02Desc,
@@ -493,69 +493,69 @@ INV AS
(
SELECT
P.CompanyCode, P.OfficeCode, P.StoreTypeCode, P.StoreCode, P.WarehouseCode,
P.ItemTypeCode, P.ItemCode, P.ColorCode, P.ItemDim1Code, P.ItemDim2Code, P.ItemDim3Code,
P.ItemTypeCode, UPPER(LTRIM(RTRIM(P.ItemCode))) AS ItemCode, P.ColorCode, P.ItemDim1Code, P.ItemDim2Code, P.ItemDim3Code,
P.Qty1 AS PickingQty1, 0 AS ReserveQty1, 0 AS DispOrderQty1, 0 AS InventoryQty1
FROM PickingStates P
INNER JOIN #AttrFiltered AF ON AF.ProductCode = P.ItemCode
INNER JOIN #AttrFiltered AF ON AF.ProductCode = UPPER(LTRIM(RTRIM(P.ItemCode)))
WHERE P.ItemTypeCode = 1
AND LEN(P.ItemCode) = 13
AND LEN(LTRIM(RTRIM(P.ItemCode))) = 13
AND P.WarehouseCode IN
(
'1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28',
'1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3',
'1-0-33','101','1-014','1-0-49','1-0-36'
'1-0-33','101','1-014','1-0-49','1-0-36','1-0-4','1-0-29'
)
UNION ALL
SELECT
R.CompanyCode, R.OfficeCode, R.StoreTypeCode, R.StoreCode, R.WarehouseCode,
R.ItemTypeCode, R.ItemCode, R.ColorCode, R.ItemDim1Code, R.ItemDim2Code, R.ItemDim3Code,
R.ItemTypeCode, UPPER(LTRIM(RTRIM(R.ItemCode))) AS ItemCode, R.ColorCode, R.ItemDim1Code, R.ItemDim2Code, R.ItemDim3Code,
0, R.Qty1, 0, 0
FROM ReserveStates R
INNER JOIN #AttrFiltered AF ON AF.ProductCode = R.ItemCode
INNER JOIN #AttrFiltered AF ON AF.ProductCode = UPPER(LTRIM(RTRIM(R.ItemCode)))
WHERE R.ItemTypeCode = 1
AND LEN(R.ItemCode) = 13
AND LEN(LTRIM(RTRIM(R.ItemCode))) = 13
AND R.WarehouseCode IN
(
'1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28',
'1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3',
'1-0-33','101','1-014','1-0-49','1-0-36'
'1-0-33','101','1-014','1-0-49','1-0-36','1-0-4','1-0-29'
)
UNION ALL
SELECT
D.CompanyCode, D.OfficeCode, D.StoreTypeCode, D.StoreCode, D.WarehouseCode,
D.ItemTypeCode, D.ItemCode, D.ColorCode, D.ItemDim1Code, D.ItemDim2Code, D.ItemDim3Code,
D.ItemTypeCode, UPPER(LTRIM(RTRIM(D.ItemCode))) AS ItemCode, D.ColorCode, D.ItemDim1Code, D.ItemDim2Code, D.ItemDim3Code,
0, 0, D.Qty1, 0
FROM DispOrderStates D
INNER JOIN #AttrFiltered AF ON AF.ProductCode = D.ItemCode
INNER JOIN #AttrFiltered AF ON AF.ProductCode = UPPER(LTRIM(RTRIM(D.ItemCode)))
WHERE D.ItemTypeCode = 1
AND LEN(D.ItemCode) = 13
AND LEN(LTRIM(RTRIM(D.ItemCode))) = 13
AND D.WarehouseCode IN
(
'1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28',
'1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3',
'1-0-33','101','1-014','1-0-49','1-0-36'
'1-0-33','101','1-014','1-0-49','1-0-36','1-0-4','1-0-29'
)
UNION ALL
SELECT
T.CompanyCode, T.OfficeCode, T.StoreTypeCode, T.StoreCode, T.WarehouseCode,
T.ItemTypeCode, T.ItemCode, T.ColorCode, T.ItemDim1Code, T.ItemDim2Code, T.ItemDim3Code,
T.ItemTypeCode, UPPER(LTRIM(RTRIM(T.ItemCode))) AS ItemCode, T.ColorCode, T.ItemDim1Code, T.ItemDim2Code, T.ItemDim3Code,
0, 0, 0, SUM(T.In_Qty1 - T.Out_Qty1)
FROM trStock T
INNER JOIN #AttrFiltered AF ON AF.ProductCode = T.ItemCode
INNER JOIN #AttrFiltered AF ON AF.ProductCode = UPPER(LTRIM(RTRIM(T.ItemCode)))
WHERE T.ItemTypeCode = 1
AND LEN(T.ItemCode) = 13
AND LEN(LTRIM(RTRIM(T.ItemCode))) = 13
AND T.WarehouseCode IN
(
'1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28',
'1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3',
'1-0-33','101','1-014','1-0-49','1-0-36'
'1-0-33','101','1-014','1-0-49','1-0-36','1-0-4','1-0-29'
)
GROUP BY
T.CompanyCode, T.OfficeCode, T.StoreTypeCode, T.StoreCode, T.WarehouseCode,
T.ItemTypeCode, T.ItemCode, T.ColorCode, T.ItemDim1Code, T.ItemDim2Code, T.ItemDim3Code
T.ItemTypeCode, UPPER(LTRIM(RTRIM(T.ItemCode))), T.ColorCode, T.ItemDim1Code, T.ItemDim2Code, T.ItemDim3Code
) X
GROUP BY
X.CompanyCode, X.OfficeCode, X.StoreTypeCode, X.StoreCode, X.WarehouseCode,
@@ -580,21 +580,21 @@ Avail AS
FROM INV I
JOIN cdItem CI
ON CI.ItemTypeCode = I.ItemTypeCode
AND CI.ItemCode = I.ItemCode
AND UPPER(LTRIM(RTRIM(CI.ItemCode))) = I.ItemCode
LEFT JOIN cdUnitOfMeasure U
ON U.UnitOfMeasureCode = CI.UnitOfMeasureCode1
LEFT JOIN cdColorDesc C
ON C.ColorCode = I.ColorCode
AND C.LangCode = 'TR'
WHERE I.ItemTypeCode = 1
AND LEN(I.ItemCode) = 13
AND LEN(LTRIM(RTRIM(I.ItemCode))) = 13
AND (I.InventoryQty1 - I.PickingQty1 - I.ReserveQty1 - I.DispOrderQty1) > 0
AND CI.IsBlocked = 0
AND I.WarehouseCode IN
(
'1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28',
'1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3',
'1-0-33','101','1-014','1-0-49','1-0-36'
'1-0-33','101','1-014','1-0-49','1-0-36','1-0-4','1-0-29'
)
),
Grouped AS
@@ -604,7 +604,7 @@ Grouped AS
A.ColorCode,
A.ItemDim2Code
FROM Avail A
INNER JOIN #AttrFiltered AF ON AF.ProductCode = A.ItemCode
INNER JOIN #AttrFiltered AF ON AF.ProductCode = UPPER(LTRIM(RTRIM(A.ItemCode)))
WHERE (@RenkList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL((CASE WHEN A.RenkAciklama <> '' THEN A.RenkAciklama ELSE A.ColorCode END),'') + NCHAR(31), NCHAR(31) + @RenkList + NCHAR(31)) > 0)
AND (@Renk2List IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(A.ItemDim2Code,'') + NCHAR(31), NCHAR(31) + @Renk2List + NCHAR(31)) > 0)
AND (
@@ -669,7 +669,7 @@ INNER JOIN Grouped G
AND G.ColorCode = A.ColorCode
AND ISNULL(G.ItemDim2Code, '') = ISNULL(A.ItemDim2Code, '')
INNER JOIN #AttrFiltered AF
ON AF.ProductCode = A.ItemCode
ON AF.ProductCode = UPPER(LTRIM(RTRIM(A.ItemCode)))
LEFT JOIN cdWarehouseDesc W
ON W.WarehouseCode = A.WarehouseCode
AND W.LangCode = 'TR'
@@ -680,8 +680,8 @@ OUTER APPLY (
SELECT TOP 1 Price
FROM prItemBasePrice PB
WHERE PB.ItemTypeCode = 1
AND PB.ItemCode = A.ItemCode
AND LEN(PB.ItemCode) = 13
AND UPPER(LTRIM(RTRIM(PB.ItemCode))) = UPPER(LTRIM(RTRIM(A.ItemCode)))
AND LEN(LTRIM(RTRIM(PB.ItemCode))) = 13
ORDER BY PB.PriceDate DESC
) P
OPTION (RECOMPILE);
+128 -17
View File
@@ -25,6 +25,8 @@ type ProductImageItem struct {
UUID string `json:"uuid,omitempty"`
ThumbURL string `json:"thumb_url,omitempty"`
FullURL string `json:"full_url,omitempty"`
StoredInDB bool `json:"stored_in_db,omitempty"`
HasDBContent bool `json:"-"`
}
type ProductImageBatchItem struct {
@@ -136,6 +138,74 @@ func extractImageUUID(storagePath, fileName string) string {
return ""
}
func enrichProductImageItem(it *ProductImageItem) {
if it == nil {
return
}
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
it.UUID = u
it.ThumbURL = productImagePublicURLIfExists("uploads/image/t300/" + u + ".jpg")
it.FullURL = productImagePublicURLIfExists("uploads/image/" + u + ".jpg")
}
if it.FullURL == "" {
it.FullURL = productImagePublicURLIfExists(it.Storage)
}
if it.FullURL == "" {
it.FullURL = productImagePublicURLIfExists(it.FileName)
}
if it.ID > 0 && (it.HasDBContent || productImageStoredFileExists(it.Storage, it.FileName)) {
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
}
}
func productImageItemHasURL(it *ProductImageItem) bool {
return it != nil && (it.ThumbURL != "" || it.FullURL != "" || it.ContentURL != "")
}
func productImageStoredFileExists(storagePath, fileName string) bool {
if resolved, _ := resolveStoragePath(storagePath); resolved != "" {
return true
}
if resolved, _ := resolveStoragePath(fileName); resolved != "" {
return true
}
return false
}
func productImagePublicURLIfExists(storagePath string) string {
raw := strings.TrimSpace(storagePath)
if raw == "" {
return ""
}
resolved, _ := resolveStoragePath(raw)
if resolved == "" {
return ""
}
normalized := strings.ReplaceAll(raw, "\\", "/")
lower := strings.ToLower(normalized)
if idx := strings.Index(lower, "/uploads/"); idx >= 0 {
return normalized[idx:]
}
if strings.HasPrefix(lower, "uploads/") {
return "/" + normalized
}
root := strings.TrimSpace(os.Getenv("BLOB_ROOT"))
if root == "" {
return ""
}
rel, err := filepath.Rel(root, resolved)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return ""
}
rel = filepath.ToSlash(rel)
if strings.HasPrefix(strings.ToLower(rel), "uploads/") {
return "/" + rel
}
return ""
}
// POST /api/product-images/batch
func PostProductImagesBatchHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -208,6 +278,8 @@ ranked AS (
COALESCE(b.file_name,'') AS file_name,
COALESCE(b.file_size,0) AS file_size,
COALESCE(b.storage_path,'') AS storage_path,
COALESCE(b.stored_in_db,false) AS stored_in_db,
(COALESCE(octet_length(b.bin),0) > 0) AS has_db_content,
ROW_NUMBER() OVER (
PARTITION BY mi.key
ORDER BY
@@ -225,9 +297,9 @@ ranked AS (
AND b.typ='img'
AND b.src_id=mi.mmitem_id
)
SELECT key, id, file_name, file_size, storage_path
SELECT key, id, file_name, file_size, storage_path, stored_in_db, has_db_content
FROM ranked
WHERE rn <= 1
WHERE rn <= 5
ORDER BY key, rn
`, string(payload))
if err != nil {
@@ -241,14 +313,12 @@ ORDER BY key, rn
for rows.Next() {
var key string
var it ProductImageItem
if err := rows.Scan(&key, &it.ID, &it.FileName, &it.FileSize, &it.Storage); err != nil {
if err := rows.Scan(&key, &it.ID, &it.FileName, &it.FileSize, &it.Storage, &it.StoredInDB, &it.HasDBContent); err != nil {
continue
}
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
it.UUID = u
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
it.FullURL = "/uploads/image/" + u + ".jpg"
enrichProductImageItem(&it)
if !productImageItemHasURL(&it) {
continue
}
grouped[key] = append(grouped[key], it)
}
@@ -339,7 +409,9 @@ SELECT
id,
COALESCE(file_name,'') AS file_name,
COALESCE(file_size,0) AS file_size,
COALESCE(storage_path,'') AS storage_path
COALESCE(storage_path,'') AS storage_path,
COALESCE(stored_in_db,false) AS stored_in_db,
(COALESCE(octet_length(bin),0) > 0) AS has_db_content
FROM dfblob
WHERE typ='img'
AND src_table='mmitem'
@@ -370,17 +442,14 @@ ORDER BY
items := make([]ProductImageItem, 0, 16)
for rows.Next() {
var it ProductImageItem
if err := rows.Scan(&it.ID, &it.FileName, &it.FileSize, &it.Storage); err != nil {
if err := rows.Scan(&it.ID, &it.FileName, &it.FileSize, &it.Storage, &it.StoredInDB, &it.HasDBContent); err != nil {
continue
}
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
it.UUID = u
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
it.FullURL = "/uploads/image/" + u + ".jpg"
}
enrichProductImageItem(&it)
if productImageItemHasURL(&it) {
items = append(items, it)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
@@ -521,7 +590,8 @@ WHERE id = $1
return
}
if storedInDB && len(binData) > 0 {
_ = storedInDB
if len(binData) > 0 {
w.Header().Set("Content-Type", http.DetectContentType(binData))
w.Header().Set("Cache-Control", "public, max-age=3600")
_, _ = w.Write(binData)
@@ -529,6 +599,9 @@ WHERE id = $1
}
resolved, _ := resolveStoragePath(storagePath)
if resolved == "" {
resolved, _ = resolveStoragePath(fileName)
}
if resolved == "" {
http.NotFound(w, r)
return
@@ -562,14 +635,42 @@ func resolveStoragePath(storagePath string) (string, []string) {
raw = filepath.ToSlash(filepath.Clean(raw))
relUploads := filepath.FromSlash(filepath.Join("uploads", raw))
baseName := filepath.Base(raw)
relImage := filepath.FromSlash(filepath.Join("uploads", "image", baseName))
relImageThumb := filepath.FromSlash(filepath.Join("uploads", "image", "t300", baseName))
candidates := []string{
filepath.Clean(storagePath),
filepath.FromSlash(filepath.Clean(strings.TrimPrefix(storagePath, "/"))),
filepath.FromSlash(filepath.Clean(raw)),
relUploads,
relImage,
relImageThumb,
filepath.Join(".", relUploads),
filepath.Join(".", relImage),
filepath.Join(".", relImageThumb),
filepath.Join("..", relUploads),
filepath.Join("..", relImage),
filepath.Join("..", relImageThumb),
filepath.Join("..", "..", relUploads),
filepath.Join("..", "..", relImage),
filepath.Join("..", "..", relImageThumb),
}
if u := extractImageUUID(raw, baseName); u != "" {
uFull := filepath.FromSlash(filepath.Join("uploads", "image", u+".jpg"))
uThumb := filepath.FromSlash(filepath.Join("uploads", "image", "t300", u+".jpg"))
candidates = append(candidates,
filepath.FromSlash(filepath.Join("image", u+".jpg")),
filepath.FromSlash(filepath.Join("image", "t300", u+".jpg")),
uFull,
uThumb,
filepath.Join(".", uFull),
filepath.Join(".", uThumb),
filepath.Join("..", uFull),
filepath.Join("..", uThumb),
filepath.Join("..", "..", uFull),
filepath.Join("..", "..", uThumb),
)
}
if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" {
@@ -577,7 +678,17 @@ func resolveStoragePath(storagePath string) (string, []string) {
filepath.Join(root, raw),
filepath.Join(root, relUploads),
filepath.Join(root, "uploads", raw),
filepath.Join(root, relImage),
filepath.Join(root, relImageThumb),
)
if u := extractImageUUID(raw, baseName); u != "" {
candidates = append(candidates,
filepath.Join(root, "uploads", "image", u+".jpg"),
filepath.Join(root, "uploads", "image", "t300", u+".jpg"),
filepath.Join(root, "image", u+".jpg"),
filepath.Join(root, "image", "t300", u+".jpg"),
)
}
}
for _, p := range candidates {
+125 -29
View File
@@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
@@ -16,7 +17,7 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
f := queries.ProductPerformanceFilters{
@@ -28,7 +29,7 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc {
Bucket: strings.TrimSpace(r.URL.Query().Get("bucket")),
SortBy: strings.TrimSpace(r.URL.Query().Get("sort_by")),
Descending: boolQuery(r, "descending", true),
Limit: intQuery(r, "limit", 100),
Limit: intQuery(r, "limit", 50000),
Page: intQuery(r, "page", 1),
}
rows, total, err := queries.ListProductPerformance(ctx, pg, f)
@@ -90,7 +91,7 @@ func GetProductPerformanceSummaryHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
summary, err := queries.GetProductPerformanceSummary(ctx, pg)
@@ -106,10 +107,10 @@ func GetProductPerformanceGeneralHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
rows, err := queries.ListProductPerformanceGeneral(ctx, pg, intQuery(r, "limit", 500))
rows, err := queries.ListProductPerformanceGeneral(ctx, pg, intQuery(r, "limit", 50000))
if err != nil {
http.Error(w, "genel urun performans KPI alinamadi: "+err.Error(), http.StatusInternalServerError)
return
@@ -122,10 +123,10 @@ func GetProductPerformanceOrdersHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
rows, err := queries.ListProductPerformanceOrderAnalysis(ctx, pg, intQuery(r, "limit", 500))
rows, err := queries.ListProductPerformanceOrderAnalysis(ctx, pg, intQuery(r, "limit", 50000))
if err != nil {
http.Error(w, "siparis analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
return
@@ -138,14 +139,14 @@ func GetProductPerformanceOrderGroupsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
rows, err := queries.ListProductPerformanceOrderGroups(
ctx,
pg,
strings.TrimSpace(r.URL.Query().Get("breakdown")),
intQuery(r, "limit", 500),
intQuery(r, "limit", 50000),
)
if err != nil {
http.Error(w, "siparis grup analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
@@ -159,10 +160,10 @@ func GetProductPerformanceOrderProductCustomersHandler(pg *sql.DB) http.HandlerF
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
rows, err := queries.ListProductPerformanceOrderProductCustomers(ctx, pg, intQuery(r, "limit", 500))
rows, err := queries.ListProductPerformanceOrderProductCustomers(ctx, pg, intQuery(r, "limit", 50000))
if err != nil {
http.Error(w, "urun musteri siparis analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
return
@@ -175,10 +176,10 @@ func GetProductPerformanceOrderMarketDetailsHandler(pg *sql.DB) http.HandlerFunc
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
rows, err := queries.ListProductPerformanceOrderMarketDetails(ctx, pg, intQuery(r, "limit", 800))
rows, err := queries.ListProductPerformanceOrderMarketDetails(ctx, pg, intQuery(r, "limit", 50000))
if err != nil {
http.Error(w, "piyasa siparis detay analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
return
@@ -191,10 +192,10 @@ func GetProductPerformanceMarketsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
rows, err := queries.ListProductPerformanceMarkets(ctx, pg, intQuery(r, "limit", 100))
rows, err := queries.ListProductPerformanceMarkets(ctx, pg, intQuery(r, "limit", 50000))
if err != nil {
http.Error(w, "piyasa performans ozeti alinamadi: "+err.Error(), http.StatusInternalServerError)
return
@@ -207,10 +208,10 @@ func GetProductPerformanceCountriesHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
rows, err := queries.ListProductPerformanceCountries(ctx, pg, intQuery(r, "limit", 100))
rows, err := queries.ListProductPerformanceCountries(ctx, pg, intQuery(r, "limit", 50000))
if err != nil {
http.Error(w, "ulke performans ozeti alinamadi: "+err.Error(), http.StatusInternalServerError)
return
@@ -223,14 +224,14 @@ func GetProductPerformanceCustomersHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
rows, err := queries.ListProductPerformanceCustomers(
ctx,
pg,
strings.TrimSpace(r.URL.Query().Get("breakdown")),
intQuery(r, "limit", 100),
intQuery(r, "limit", 50000),
)
if err != nil {
http.Error(w, "musteri performans kirilimi alinamadi: "+err.Error(), http.StatusInternalServerError)
@@ -244,14 +245,14 @@ func GetProductPerformanceSalesBreakdownHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 45*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
rows, err := queries.ListProductPerformanceSalesBreakdown(
ctx,
pg,
strings.TrimSpace(r.URL.Query().Get("mode")),
intQuery(r, "limit", 500),
intQuery(r, "limit", 50000),
)
if err != nil {
http.Error(w, "satis kpi kirilimi alinamadi: "+err.Error(), http.StatusInternalServerError)
@@ -265,19 +266,41 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second)
defer cancel()
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
mainGroup := strings.TrimSpace(r.URL.Query().Get("urun_ana_grubu"))
sortBy := strings.TrimSpace(r.URL.Query().Get("sort_by"))
descending := boolQuery(r, "descending", true)
groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels"))
limit := intQuery(r, "limit", 50000)
expanded := map[string]bool{}
filters := map[string][]string{}
expandThroughLevel := -1
if rawExpandLevel := strings.TrimSpace(r.URL.Query().Get("expand_through_level")); rawExpandLevel != "" {
if parsed, err := strconv.Atoi(rawExpandLevel); err == nil {
expandThroughLevel = parsed
}
}
const maxExpandedGroupKeys = 1000
addExpandedKey := func(key string) {
key = strings.TrimSpace(key)
if key != "" && len(expanded) < maxExpandedGroupKeys {
expanded[key] = true
}
}
if r.Method == http.MethodPost {
var body struct {
Mode string `json:"mode"`
GroupLevels []string `json:"group_levels"`
ExpandedKeys []string `json:"expanded_keys"`
ExpandThroughLevel *int `json:"expand_through_level"`
Limit int `json:"limit"`
MainGroup string `json:"urun_ana_grubu"`
Filters map[string][]string `json:"filters"`
SortBy string `json:"sort_by"`
Descending *bool `json:"descending"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest)
@@ -286,6 +309,9 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
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
}
@@ -293,17 +319,23 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
limit = body.Limit
}
for _, key := range body.ExpandedKeys {
key = strings.TrimSpace(key)
if key != "" {
expanded[key] = true
addExpandedKey(key)
}
if body.ExpandThroughLevel != nil {
expandThroughLevel = *body.ExpandThroughLevel
}
if len(body.Filters) > 0 {
filters = body.Filters
}
if strings.TrimSpace(body.SortBy) != "" {
sortBy = strings.TrimSpace(body.SortBy)
}
if body.Descending != nil {
descending = *body.Descending
}
} else {
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
key = strings.TrimSpace(key)
if key != "" {
expanded[key] = true
}
addExpandedKey(key)
}
}
@@ -311,9 +343,15 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
Mode: mode,
GroupLevels: groupLevels,
ExpandedKeys: expanded,
ExpandThroughLevel: expandThroughLevel,
Limit: limit,
MainGroup: mainGroup,
Filters: filters,
SortBy: sortBy,
Descending: descending,
})
if err != nil {
log.Printf("[ProductPerformance][grouped] failed trace_id=%s mode=%s levels=%v expand_through_level=%d filters=%d: %v", traceID, mode, groupLevels, expandThroughLevel, len(filters), err)
http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -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 {
parts := strings.Split(value, ",")
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
}
if argeDB := db.GetArgeBaggiDB(); argeDB != nil && len(list) > 0 {
workOrders := make([]string, 0, len(list))
for i := range list {
workOrders = append(workOrders, list[i].UrtSiparisNo)
}
argeCtx, cancelArge := context.WithTimeout(r.Context(), 15*time.Second)
totalsByWorkOrder, argeErr := queries.GetArgeBaggiDepotEntryTotals(argeCtx, argeDB, workOrders)
cancelArge()
if argeErr != nil {
log.Printf("[ProductionNoCost] ArgeBAGGI enrichment error: %v", argeErr)
} else {
for i := range list {
totals := totalsByWorkOrder[strings.TrimSpace(list[i].UrtSiparisNo)]
list[i].CeketDepoGiris = totals.Ceket
list[i].PantolonDepoGiris = totals.Pantolon
list[i].YelekDepoGiris = totals.Yelek
}
}
}
_ = json.NewEncoder(w).Encode(list)
}
@@ -191,6 +211,7 @@ func GetProductionHasCostProductsHandler(w http.ResponseWriter, r *http.Request)
var item models.ProductionHasCostProductRow
if err := rows.Scan(
&item.UretimSekli,
&item.UrtSiparisNo,
&item.NOnMLNo,
&item.UrunKodu,
&item.UrunAdi,
@@ -219,6 +240,26 @@ func GetProductionHasCostProductsHandler(w http.ResponseWriter, r *http.Request)
return
}
if argeDB := db.GetArgeBaggiDB(); argeDB != nil && len(list) > 0 {
workOrders := make([]string, 0, len(list))
for i := range list {
workOrders = append(workOrders, list[i].UrtSiparisNo)
}
argeCtx, cancelArge := context.WithTimeout(r.Context(), 15*time.Second)
totalsByWorkOrder, argeErr := queries.GetArgeBaggiDepotEntryTotals(argeCtx, argeDB, workOrders)
cancelArge()
if argeErr != nil {
log.Printf("[ProductionHasCost] ArgeBAGGI enrichment error: %v", argeErr)
} else {
for i := range list {
totals := totalsByWorkOrder[strings.TrimSpace(list[i].UrtSiparisNo)]
list[i].CeketDepoGiris = totals.Ceket
list[i].PantolonDepoGiris = totals.Pantolon
list[i].YelekDepoGiris = totals.Yelek
}
}
}
_ = json.NewEncoder(w).Encode(list)
}
@@ -1427,6 +1468,12 @@ func postProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
totalUSD := 0.0
totalEUR := 0.0
for _, r := range req.Detail.Upserts {
// The top header in the detail screen only includes rows marked
// "Maliyete Dahil". Persist that exact business total to the master
// record as well; excluded detail rows must not inflate the cost.
if r.MaliyeteDahil != 1 {
continue
}
qty := r.LMiktar
if qty < 0 {
qty = 0
+15 -1
View File
@@ -1,8 +1,20 @@
// quasar.config.js
import { defineConfig } from '#q-app/wrappers'
import { execSync } from 'node:child_process'
function readGitBuildInfo () {
try {
const commitCount = execSync('git rev-list --count HEAD', { encoding: 'utf8' }).trim()
const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim()
return { version: `v1.0.${commitCount}`, commit: commitHash }
} catch {
return { version: 'v1.0.0', commit: 'local' }
}
}
export default defineConfig(() => {
const apiBaseUrl = (process.env.VITE_API_BASE_URL || '/api').trim()
const gitBuildInfo = readGitBuildInfo()
return {
@@ -36,7 +48,9 @@ export default defineConfig(() => {
build: {
vueRouterMode: 'hash',
env: {
VITE_API_BASE_URL: apiBaseUrl
VITE_API_BASE_URL: apiBaseUrl,
APP_VERSION: gitBuildInfo.version,
APP_COMMIT: gitBuildInfo.commit
},
esbuildTarget: {
+21
View File
@@ -74,6 +74,10 @@
</q-form>
</q-card>
<div class="login-version" :title="`Commit: ${appCommit}`">
Baggi BSS {{ appVersion }}
</div>
<!-- 🔐 FORGOT PASSWORD -->
<q-dialog v-model="forgotOpen" persistent>
<q-card style="width:420px; max-width:90vw">
@@ -129,6 +133,8 @@ import api from 'src/services/api'
const router = useRouter()
const auth = useAuthStore()
const $q = useQuasar()
const appVersion = process.env.APP_VERSION || 'v1.0.0'
const appCommit = process.env.APP_COMMIT || 'local'
const username = ref('')
const password = ref('')
@@ -266,6 +272,21 @@ async function sendResetMail () {
.full-width {
width: 100%;
}
.login-version {
position: absolute;
left: 16px;
bottom: 12px;
z-index: 2;
padding: 4px 8px;
border-radius: 6px;
color: rgba(45, 45, 45, 0.75);
background: rgba(255, 255, 255, 0.68);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.2px;
user-select: text;
}
/* ===============================
LOGIN INPUT TEXT COLOR (GOLD)
=============================== */
+2 -4
View File
@@ -1289,14 +1289,12 @@ function normalizeUploadsPath (storagePath) {
function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return ''
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
const contentUrl = toText(item.content_url || item.ContentURL)
if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl
const thumbUrl = toText(item.thumb_url || item.thumbUrl)
if (thumbUrl) return thumbUrl
const fullUrl = toText(item.full_url || item.fullUrl)
if (fullUrl) return fullUrl
const contentUrl = toText(item.content_url || item.ContentURL)
if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage)
if (uploadsPath) return uploadsPath
const fileName = toText(item.file_name || item.FileName)
+2743 -562
View File
@@ -1,4 +1,4 @@
<template>
<template>
<q-page class="product-performance-page q-pa-sm">
<q-inner-loading :showing="pageBusy">
<q-spinner-gears size="52px" color="primary" />
@@ -14,7 +14,8 @@
@wheel.stop
>
<q-spinner-gears size="56px" color="primary" />
<div class="page-busy-label">Yükleniyor...</div>
<div class="page-busy-label">{{ loadingMessage }}</div>
<div v-if="loadingElapsedLabel" class="page-busy-time">{{ loadingElapsedLabel }}</div>
</div>
</teleport>
<div class="performance-top-bar row items-center justify-between q-col-gutter-xs q-mb-xs">
@@ -34,7 +35,18 @@
:disable="pageBusy"
class="period-selector bg-white q-px-xs q-py-none"
/>
<q-btn
dense
outline
color="primary"
icon="view_column"
:label="detailColumnsHidden ? 'Detay Kolonları Göster' : 'Detay Kolonları Gizle'"
:disable="pageBusy"
@click="detailColumnsHidden = !detailColumnsHidden"
/>
<q-btn-dropdown
v-model="detailLevelMenuOpen"
split
dense
outline
color="primary"
@@ -52,11 +64,12 @@
/>
<q-separator class="q-my-sm" />
<div class="row items-center justify-end q-gutter-xs">
<q-btn dense flat color="grey-7" label="Kapat" :disable="pageBusy" @click="collapseAllProductGroups" />
<q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click="expandSelectedProductGroups" />
<q-btn dense flat color="grey-7" label="Kapat" :disable="pageBusy" @click.stop="collapseAllProductGroups" />
<q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click.stop="expandSelectedProductGroups" />
</div>
</div>
</q-btn-dropdown>
<q-btn dense outline color="positive" icon="grid_on" label="Excel" :loading="excelExportLoading" :disable="pageBusy || excelExportLoading" @click="exportProductPerformanceExcel" />
<q-btn dense outline color="secondary" icon="refresh" label="Yenile" :loading="loading" :disable="pageBusy" @click="reload" />
</div>
</div>
@@ -86,18 +99,28 @@
</button>
</div>
<div ref="topScrollbarRef" class="performance-top-scrollbar q-mb-xs">
<div ref="topScrollbarInnerRef" class="performance-top-scrollbar-inner"></div>
</div>
<q-table
v-if="activeTab === 'general'"
flat
bordered
row-key="row_key"
class="performance-table sticky-dim-table sticky-dim-7 bg-white"
:class="[
'performance-table',
'sticky-dim-table',
detailColumnsHidden ? 'sticky-dim-4' : 'sticky-dim-7',
'bg-white'
]"
:rows="filteredGeneralRows"
:columns="generalColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score_total', descending: true }"
:columns="visibleGeneralColumns"
:loading="activeTableLoading"
v-model:pagination="tablePagination.general"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -176,7 +199,7 @@
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="markProductImageFailed(props.row)"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
@@ -205,10 +228,12 @@
class="performance-table sticky-dim-table sticky-dim-8 bg-white"
:rows="displayOrderProductCustomerTableRows"
:columns="orderProductCustomerColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
:loading="activeTableLoading"
v-model:pagination="tablePagination.order_product_customers"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -287,7 +312,7 @@
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="markProductImageFailed(props.row)"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
@@ -320,7 +345,7 @@
<q-tr
v-if="props.row.__group"
:props="props"
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
:class="groupRowClasses(props.row)"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<template v-if="col.name === 'image' && groupShowsImage(props.row)">
@@ -329,7 +354,7 @@
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="markProductImageFailed(props.row)"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
@@ -350,10 +375,11 @@
dense
round
size="sm"
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
:disable="!groupCanExpand(props.row)"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
@@ -366,7 +392,7 @@
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="markProductImageFailed(props.row)"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
@@ -394,10 +420,12 @@
class="performance-table sticky-dim-table sticky-dim-10 bg-white"
:rows="displayOrderMarketDetailTableRows"
:columns="orderMarketDetailColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
:loading="activeTableLoading"
v-model:pagination="tablePagination.order_market_details"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -490,7 +518,7 @@
<q-tr
v-if="props.row.__group"
:props="props"
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
:class="groupRowClasses(props.row)"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<div
@@ -503,10 +531,11 @@
dense
round
size="sm"
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
:disable="!groupCanExpand(props.row)"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
@@ -523,17 +552,25 @@
</q-table>
<q-table
v-else-if="activeTab === 'products'"
v-else-if="isProductKpiTab"
flat
bordered
row-key="row_key"
class="performance-table product-breakdown-table bg-white"
:rows="displayProductTableRows"
:columns="productColumns"
:loading="loading || backendGroupedLoading"
:pagination="pagination"
:class="[
'performance-table',
'product-breakdown-table',
'bg-white',
{ 'compact-detail-columns': detailColumnsHidden },
activeTab === 'product_detail' ? 'product-detail-table' : 'product-summary-table'
]"
:rows="displayProductKpiTableRows"
:columns="activeProductColumns"
:loading="activeTableLoading"
v-model:pagination="activeProductPagination"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -546,17 +583,17 @@
round
size="8px"
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"
@click.stop
>
<q-badge v-if="hasColumnFilter('products', props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('products', props.col.name) }}
<q-badge v-if="hasColumnFilter(activeProductTableKey, props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue(activeProductTableKey, props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey('products', props.col.name)]"
v-model="columnFilterSearch[filterKey(activeProductTableKey, props.col.name)]"
dense
outlined
clearable
@@ -564,13 +601,13 @@
placeholder="Ara"
/>
<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="Temizle" @click="clearColumnFilter('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(activeProductTableKey, props.col.name)" />
</div>
<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"
:items="columnFilterOptions('products', props.col.name)"
:items="columnFilterOptions(activeProductTableKey, props.col.name)"
:virtual-scroll-item-size="32"
separator
>
@@ -580,14 +617,14 @@
dense
clickable
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-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected('products', props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('products', props.col.name, option.value)"
:model-value="isColumnFilterValueSelected(activeProductTableKey, props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue(activeProductTableKey, props.col.name, option.value)"
@click.stop
/>
</q-item-section>
@@ -610,7 +647,7 @@
<q-tr
v-if="props.row.__group"
:props="props"
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
:class="groupRowClasses(props.row)"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<template v-if="col.name === 'image' && groupShowsImage(props.row)">
@@ -619,7 +656,7 @@
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="markProductImageFailed(props.row)"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
@@ -640,10 +677,11 @@
dense
round
size="sm"
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
:disable="!groupCanExpand(props.row)"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col.name) }}</span>
</q-td>
@@ -656,7 +694,7 @@
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="markProductImageFailed(props.row)"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
@@ -684,10 +722,12 @@
class="performance-table sticky-dim-table sticky-dim-11 bg-white"
:rows="displayIdleTableRows"
:columns="idleColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100 }"
:loading="activeTableLoading"
v-model:pagination="tablePagination.idle"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -766,7 +806,7 @@
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="markProductImageFailed(props.row)"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
@@ -795,7 +835,7 @@
<q-tr
v-if="props.row.__group"
:props="props"
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
:class="groupRowClasses(props.row)"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<template v-if="col.name === 'image' && groupShowsImage(props.row)">
@@ -804,7 +844,7 @@
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="markProductImageFailed(props.row)"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
@@ -825,10 +865,11 @@
dense
round
size="sm"
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
:disable="!groupCanExpand(props.row)"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
@@ -841,7 +882,7 @@
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="markProductImageFailed(props.row)"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
@@ -869,10 +910,11 @@
class="performance-table sticky-dim-table sticky-dim-9 bg-white"
:rows="filteredMarketRows"
:columns="marketColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
:loading="activeTableLoading"
v-model:pagination="tablePagination.markets"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -966,13 +1008,21 @@
flat
bordered
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"
:columns="visibleSalesBreakdownColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score_total', descending: true }"
:loading="activeTableLoading"
v-model:pagination="activeSalesBreakdownPagination"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -1070,7 +1120,7 @@
<q-tr
v-if="props.row.__group"
:props="props"
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
:class="groupRowClasses(props.row)"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<div
@@ -1083,10 +1133,11 @@
dense
round
size="sm"
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
:disable="!groupCanExpand(props.row)"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
@@ -1126,10 +1177,11 @@
class="performance-table sticky-dim-table sticky-dim-5 bg-white"
:rows="filteredCustomerRows"
:columns="customerColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
:loading="activeTableLoading"
v-model:pagination="tablePagination.customers"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -1227,10 +1279,11 @@
class="performance-table sticky-dim-table sticky-dim-3 bg-white"
:rows="filteredCountryRows"
:columns="countryColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
:loading="activeTableLoading"
v-model:pagination="tablePagination.countries"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -1366,7 +1419,7 @@
:name="idx"
class="column no-wrap flex-center"
>
<q-img :src="url" fit="contain" class="product-image-large" />
<q-img :src="url" fit="contain" class="product-image-large" @error="markDialogImageFailed(url)" />
</q-carousel-slide>
</q-carousel>
<div v-else class="product-image-empty">
@@ -1390,12 +1443,13 @@
flat
bordered
dense
class="dialog-detail-table"
title="Satış / Müşteri Özeti"
row-key="detail_key"
:rows="detailSalesRows"
:columns="detailSalesColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 50 }"
:pagination="{ rowsPerPage: 0, sortBy: 'sales_usd', descending: true }"
/>
</section>
</div>
@@ -1431,12 +1485,13 @@
flat
bordered
dense
class="dialog-stock-table"
title="Beden Stok"
row-key="size_code"
:rows="detailStockSizes"
:columns="detailStockColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 50 }"
:pagination="{ rowsPerPage: 0 }"
virtual-scroll
:virtual-scroll-item-size="48"
/>
@@ -1446,12 +1501,13 @@
flat
bordered
dense
class="dialog-detail-table"
title="Satış / Müşteri Özeti"
row-key="detail_key"
:rows="detailSalesRows"
:columns="detailSalesColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 50 }"
:pagination="{ rowsPerPage: 0, sortBy: 'sales_usd', descending: true }"
virtual-scroll
:virtual-scroll-item-size="48"
/>
@@ -1464,16 +1520,35 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Notify } from 'quasar'
import api from 'src/services/api'
import api, { extractApiErrorDetail } from 'src/services/api'
import { useProductPerformanceStore } from 'src/stores/productPerformanceStore'
import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters'
const performanceStore = useProductPerformanceStore()
function formatNumber (value, fraction = 2) {
const n = Number(value || 0)
const requested = Number(fraction)
const digits = Number.isFinite(requested) ? Math.max(0, Math.min(4, requested)) : 2
return n.toLocaleString('tr-TR', {
minimumFractionDigits: digits,
maximumFractionDigits: digits
})
}
function formatMoney (value, currency = 'USD') {
return `${formatNumber(value, 2)} ${currency}`
}
function formatPercent (value) {
return `${formatNumber(Number(value || 0) * 100, 2)}%`
}
const loading = ref(false)
const excelExportLoading = ref(false)
const rows = ref([])
const detailMainGroupOptionRows = ref([])
const generalRows = ref([])
const orderAnalysisRows = ref([])
const orderMarketRows = ref([])
@@ -1483,6 +1558,9 @@ const orderMarketDetailRows = ref([])
const orderAnalysisLoaded = ref(false)
const orderProductCustomersLoaded = ref(false)
const orderMarketDetailsLoaded = ref(false)
const marketRowsLoaded = ref(false)
const countryRowsLoaded = ref(false)
const customerRowsLoaded = ref(false)
const orderAnalysisMode = ref('product')
const marketRows = ref([])
const countryRows = ref([])
@@ -1497,8 +1575,11 @@ const summary = ref({})
const activeTab = ref('products')
const customerBreakdown = ref('market_customer')
const selectedPeriods = ref(['90', '180', '360', 'total'])
const selectedDetailMainGroup = ref('')
const imageUrlByKey = ref({})
const imageListByKey = ref({})
const imageFailedKeys = ref(new Set())
const imageLoadingKeys = ref(new Set())
const imageDialog = ref(false)
const imageDialogUrls = ref([])
const imageDialogTitle = ref('')
@@ -1513,45 +1594,151 @@ const detailLoading = ref(false)
const detailSalesRows = ref([])
const detailStockSizes = ref([])
const backendGroupedRows = ref({})
const backendGroupedFilterOptions = ref({})
const backendGroupedLoading = ref(false)
const imagePrimeLoading = ref(false)
const filterBusy = ref(false)
const bulkExpandLoading = ref(false)
const loadingStartedAt = ref(0)
const loadingNow = ref(0)
const loadingStage = ref('')
const generalRowsLoaded = ref(false)
const detailLevelMenuOpen = ref(false)
const detailColumnsHidden = ref(false)
const topScrollbarRef = ref(null)
const topScrollbarInnerRef = ref(null)
let backendGroupedTimer = null
let imagePrimeActiveRequests = 0
let activeTableMiddleEl = null
let topScrollbarResizeObserver = null
let topScrollbarSyncing = false
let loadingTimer = null
const columnFilters = reactive({})
const columnFilterSearch = reactive({})
const expandedGroups = ref({})
const lastManualExpandedGroupKey = ref('')
const selectedExpandLevelKeysByTab = reactive({
products: ['urun_ilk_grubu', 'askili_yan', 'kategori', 'urun_ana_grubu', 'urun_alt_grubu'],
sales_color_yaka_market_customer: ['color_code', 'yaka_kodu', 'urun_ilk_grubu', 'askili_yan'],
idle: ['urun_ilk_grubu', 'askili_yan', 'kategori', 'urun_ana_grubu', 'urun_alt_grubu'],
sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
products: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
product_detail: ['urun_alt_grubu'],
sales_color_yaka_market_customer: ['urun_ana_grubu', 'color_yaka'],
idle: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
sales_product_country_segment_market_customer: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
sales_country_segment_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
order_product_customers: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
sales_country_segment_market_customer_product: ['country', 'customer_segment', 'market_key'],
order_product_customers: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
order_market_details: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan']
})
const productKpiFetchLimit = 50000
const maxBulkExpandKeys = 1200
const reportFetchLimit = 50000
const maxBulkExpandKeys = 1000
const maxBackendExpandedKeys = 1000
const tableVirtualSliceSize = 160
const productThumbVirtualItemSize = 236
const maxRenderedGroupedRows = 2500
const maxAutoExpandLevelByTab = {
products: 8,
product_detail: 8,
idle: 8,
sales_color_yaka_market_customer: 8,
sales_product_country_segment_market_customer: 8,
sales_market_customer_product: 8,
sales_country_segment_market_customer_product: 8,
order_product_customers: 8,
order_market_details: 8
}
const backendGroupedFilterFields = new Set([
'kategori',
'askili_yan',
'urun_ilk_grubu',
'urun_ana_grubu',
'urun_alt_grubu',
'product_code',
'color_yaka',
'market_key',
'country',
'customer_segment',
'customer_code',
'customer_name',
'performance_bucket'
])
const productPerformanceExcelExportFilterFields = new Set([
'kategori',
'askili_yan',
'urun_ilk_grubu',
'urun_ana_grubu',
'urun_alt_grubu',
'product_code',
'color_yaka',
'color_code',
'yaka_kodu',
'market_key',
'performance_bucket'
])
const performanceTabs = [
{ name: 'products', icon: 'inventory_2', label: 'Ürün KPI' },
{ name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Renk > Yaka > Piyasa > Müşteri' },
{ name: 'products', icon: 'dashboard', label: 'Genel Özet KPI' },
{ name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Ürün Ana Grubu > Renk/Yaka > Piyasa > Müşteri' },
{ name: 'idle', icon: 'warning', label: 'Atıl Stok / Maliyet' },
{ name: 'sales_product_country_segment_market_customer', icon: 'account_tree', label: 'Ürün > Ülke > Segment > Piyasa > Müşteri' },
{ name: 'sales_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' },
{ name: 'order_product_customers', icon: 'assignment_ind', label: 'Ürün > Piyasa > Müşteri Sipariş' },
{ name: 'order_market_details', icon: 'receipt_long', label: 'Piyasa > Müşteri > Ürün Sipariş' }
]
const pageBusy = computed(() => loading.value || backendGroupedLoading.value || imagePrimeLoading.value || filterBusy.value)
const pagination = ref({
page: 1,
rowsPerPage: 100,
sortBy: 'performance_score_total',
descending: true
const pageBusy = computed(() => loading.value)
const activeTableLoading = computed(() => loading.value || (backendGroupedSupportedTab(activeTab.value) && backendGroupedLoading.value))
const loadingElapsedLabel = computed(() => {
if (!loadingStartedAt.value || !loadingNow.value) return ''
const seconds = Math.max(0, Math.floor((loadingNow.value - loadingStartedAt.value) / 1000))
const minutes = Math.floor(seconds / 60)
const rest = seconds % 60
return minutes > 0 ? `${minutes}:${String(rest).padStart(2, '0')}` : `${seconds} sn`
})
const loadingMessage = computed(() => loadingStage.value || 'Rapor verileri yükleniyor...')
const tablePagination = reactive({
general: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
products: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
product_detail: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
order_product_customers: { page: 1, rowsPerPage: 0, sortBy: 'order_usd', descending: true },
order_market_details: { page: 1, rowsPerPage: 0, sortBy: 'order_date', descending: true },
idle: { page: 1, rowsPerPage: 0, sortBy: 'idle_cost_usd', descending: true },
markets: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_90d', descending: true },
countries: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_90d', descending: true },
customers: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_365d', descending: true },
sales_color_yaka_market_customer: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
sales_product_country_segment_market_customer: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
sales_market_customer_product: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
sales_country_segment_market_customer_product: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }
})
const activeProductPagination = computed({
get: () => tablePagination[activeTab.value === 'product_detail' ? 'product_detail' : 'products'],
set: value => {
tablePagination[activeTab.value === 'product_detail' ? 'product_detail' : 'products'] = normalizeTablePagination(value)
}
})
const activeSalesBreakdownPagination = computed({
get: () => tablePagination[activeTab.value] || tablePagination.sales_color_yaka_market_customer,
set: value => {
tablePagination[activeTab.value] = normalizeTablePagination(value)
}
})
const activeBackendGroupedSortSignature = computed(() => {
if (!backendGroupedSupportedTab(activeTab.value)) return ''
const state = tablePagination[activeTab.value] || {}
return `${activeTab.value}|${state.sortBy || ''}|${state.descending !== false ? 1 : 0}`
})
function normalizeTablePagination (value) {
return {
page: Number(value?.page || 1),
rowsPerPage: 0,
sortBy: value?.sortBy || '',
descending: value?.descending === true
}
}
const bucketOptions = [
{ label: 'Yıldız Ürün', value: 'YILDIZ_URUN' },
@@ -1596,9 +1783,9 @@ const detailSalesColumns = [
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'sales_qty', label: 'Adet', field: row => formatNumber(row.sales_qty, 0), align: 'right', sortable: true },
{ name: 'sales_qty', label: 'Adet', field: row => formatNumber(row.sales_qty, 2), align: 'right', sortable: true },
{ name: 'sales_usd', label: 'USD', field: row => formatMoney(row.sales_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd, 'USD'), align: 'right', sortable: true }
{ name: 'avg_price_usd', label: 'Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd, 'USD'), align: 'right', sortable: true }
]
const columns = [
@@ -1614,13 +1801,13 @@ const columns = [
{ 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: '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: '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: '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: '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_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 },
@@ -1628,17 +1815,18 @@ const columns = [
{ name: 'sales_usd_total', label: 'Genel USD', field: 'sales_usd_total', align: 'right', sortable: true },
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true },
{ name: 'stock_days_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 1), align: 'right', sortable: true },
{ name: 'stock_days_365d', label: '360G Stok Gün', field: row => formatNumber(row.stock_days_365d, 1), align: 'right', sortable: true },
{ name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_180d', label: '180G Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_365d', label: '360G Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_total', label: 'Genel Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right' },
{ name: 'base_price_usd', label: 'Ort. USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'Ort. USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_180d', label: '180G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_365d', label: '360G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_total', label: 'Genel Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right' },
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_90d', label: '90G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_90d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_90d', label: '90G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_90d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true },
@@ -1648,6 +1836,8 @@ const columns = [
{ name: 'gross_profit_usd_90d', label: '90G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_180d', label: '180G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_total', label: 'Genel Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_cost_usd_total', label: 'Genel Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_base_90d', label: '90G Taban Marj', field: row => formatPercent(row.gross_margin_base_90d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_90d', label: '90G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_90d), align: 'right', sortable: true },
{ name: 'gross_margin_base_180d', label: '180G Taban Marj', field: row => formatPercent(row.gross_margin_base_180d), align: 'right', sortable: true },
@@ -1660,7 +1850,6 @@ const columns = [
{ name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
{ name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true },
{ name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
@@ -1679,9 +1868,26 @@ const periodOrder = {
total: 4
}
const hiddenTableColumns = new Set(['item_description', 'performance_score'])
const hiddenTableColumns = new Set([
'item_description',
'performance_score',
'gross_profit_usd_total',
'invoice_count_90d',
'invoice_count_180d',
'invoice_count_365d',
'invoice_count_total',
'sales_index_90d',
'sales_index_180d',
'sales_index_365d',
'sales_index_total',
'stock_days_90d',
'stock_days_180d',
'stock_days_365d',
'stock_days_total'
])
const dimensionColumnNames = [
'product_code',
'color_yaka',
'color_code',
'yaka_kodu',
'item_description',
@@ -1815,6 +2021,35 @@ function reorderColumnsInPlace (targetColumns) {
}
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 = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
@@ -1831,29 +2066,33 @@ const generalColumns = [
{ name: 'period_start', label: 'Başlangıç', field: 'period_start', align: 'left', sortable: true },
{ name: 'period_end', label: 'Bitiş', field: 'period_end', align: 'left', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'sales_qty_total', label: 'Toplam Satış', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_total', label: 'Toplam USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'sales_qty_total', label: 'Toplam Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_total', label: 'Toplam Ciro USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'avg_daily_sales_total', label: 'Günlük Ort.', field: row => formatNumber(row.avg_daily_sales_total, 3), align: 'right', sortable: true },
{ name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true },
{ name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_total', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_total', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_total', label: 'Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_total', label: 'Taban Toplam K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_cost_usd_total', label: 'Çıplak Toplam K/Z', field: row => formatMoney(row.gross_profit_cost_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_base_total', label: 'Genel Taban Marj', field: row => formatPercent(row.gross_margin_base_total), align: 'right', sortable: true },
{ name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true },
{ name: 'market_count_total', label: 'Toplam Piyasa', field: row => formatNumber(row.market_count_total, 0), align: 'right', sortable: true },
{ name: 'customer_count_total', label: 'Toplam Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'invoice_count_total', label: 'Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
{ name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true },
{ name: 'performance_score', label: 'Genel Skor', field: row => formatNumber(row.performance_score, 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: 'first_sale_date', label: 'İlk Satış', field: 'first_sale_date', align: 'left', sortable: true },
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const visibleGeneralColumns = computed(() => applyDetailColumnVisibility(generalColumns))
const orderAnalysisColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
@@ -1896,7 +2135,7 @@ const orderGroupColumns = [
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'base_cost_usd', label: 'Taban Maliyet USD', field: row => formatMoney(row.base_cost_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_amount_usd', label: 'Çıplak Maliyet USD', field: row => formatMoney(row.cost_amount_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
@@ -1930,7 +2169,7 @@ const orderProductCustomerColumns = [
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'Taban USD', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
@@ -1964,7 +2203,7 @@ const orderMarketDetailColumns = [
{ name: 'due_date', label: 'Termin', field: 'due_date', align: 'left', sortable: true },
{ name: 'order_qty', label: 'Adet', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
@@ -1993,7 +2232,7 @@ const idleColumns = [
{ name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
@@ -2024,7 +2263,7 @@ const countryColumns = [
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: row => formatMoney(row.sales_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'customer_count_90d', label: 'Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true },
{ name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
{ name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
@@ -2040,8 +2279,8 @@ const customerColumns = [
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
{ name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 1), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. Satış Fiyatı USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
{ name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_90d', label: '90G Taban', field: row => formatMoney(row.base_price_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_90d', label: '90G Çıplak', field: row => formatMoney(row.cost_price_usd_90d, 'USD'), align: 'right', sortable: true },
{ 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 },
@@ -2075,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: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'customer_count_180d', label: '180G Müşteri', field: row => formatNumber(row.customer_count_180d, 0), align: 'right', sortable: true },
{ name: 'invoice_count_180d', label: '180G Fatura', field: row => formatNumber(row.invoice_count_180d, 0), align: 'right', sortable: true },
{ name: 'sales_qty_180d', label: '180G Adet', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_180d', label: '180G USD', field: row => formatMoney(row.sales_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_180d', label: '180G Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
{ name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_180d', label: '180G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_180d', label: '180G Taban', field: row => formatMoney(row.base_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_180d', label: '180G Çıplak', field: row => formatMoney(row.cost_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_180d', label: '180G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_180d, 'USD'), align: 'right', sortable: true },
{ 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_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_usd_365d', label: '360G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_365d', label: '360G Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
{ name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_365d', label: '360G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_365d', label: '360G Taban', field: row => formatMoney(row.base_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_365d', label: '360G Çıplak', field: row => formatMoney(row.cost_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_365d', label: '360G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_365d, 'USD'), align: 'right', sortable: true },
{ 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_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: 'invoice_count_total', label: 'Genel Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
{ name: 'sales_qty_total', label: 'Genel Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_total', label: 'Genel USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_total', label: 'Genel Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
{ name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_total', label: 'Genel Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_total', label: 'Genel Taban', field: row => formatMoney(row.base_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_total', label: 'Genel Çıplak', field: row => formatMoney(row.cost_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
{ 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_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: '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: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
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_180d', label: '180G Ürün Skor', field: row => formatNumber(row.performance_score_180d, 1), 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_total', label: 'Genel Ürün Skor', field: row => formatNumber(row.performance_score_total, 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, 2), 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, 2), align: 'right', sortable: true }
]
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_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 1), 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_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 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, 2), 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, 2), align: 'right', sortable: true }
]
const metricLabelOverrides = {
stock_qty: 'Toplam Stok',
sales_qty: 'Toplam Adet',
sales_qty_90d: '90G Toplam Adet',
sales_qty_180d: '180G Toplam Adet',
sales_qty_365d: '360G Toplam Adet',
sales_qty_total: 'Genel Toplam Adet',
sales_usd: 'Toplam Ciro USD',
sales_usd_90d: '90G Toplam Ciro USD',
sales_usd_180d: '180G Toplam Ciro USD',
sales_usd_365d: '360G Toplam Ciro USD',
sales_usd_total: 'Genel Toplam Ciro USD',
avg_price_usd: 'Ort. Satış Fiyatı USD',
avg_price_usd_90d: '90G Ort. Satış Fiyatı USD',
avg_price_usd_180d: '180G Ort. Satış Fiyatı USD',
avg_price_usd_365d: '360G Ort. Satış Fiyatı USD',
avg_price_usd_total: 'Genel Ort. Satış Fiyatı USD',
base_price_usd: 'Taban Maliyet USD',
cost_price_usd: 'Çıplak Maliyet USD',
base_price_usd_90d: '90G Taban Maliyet USD',
cost_price_usd_90d: '90G Çıplak Maliyet USD',
base_price_usd_180d: '180G Taban Maliyet USD',
cost_price_usd_180d: '180G Çıplak Maliyet USD',
base_price_usd_365d: '360G Taban Maliyet USD',
cost_price_usd_365d: '360G Çıplak Maliyet USD',
base_price_usd_total: 'Genel Taban Maliyet USD',
cost_price_usd_total: 'Genel Çıplak Maliyet USD',
unit_profit_base_90d: '90G Birim Taban K/Z USD',
unit_profit_cost_90d: '90G Birim Çıplak K/Z USD',
unit_profit_base_180d: '180G Birim Taban K/Z USD',
unit_profit_cost_180d: '180G Birim Çıplak K/Z USD',
unit_profit_base_total: 'Genel Birim Taban K/Z USD',
unit_profit_cost_total: 'Genel Birim Çıplak K/Z USD',
gross_profit_usd_90d: '90G Toplam K/Z USD',
gross_profit_usd_180d: '180G Toplam K/Z USD',
gross_profit_usd_total: 'Genel Toplam K/Z USD',
gross_profit_base_usd_90d: '90G Taban Toplam K/Z USD',
gross_profit_cost_usd_90d: '90G Çıplak Toplam K/Z USD',
gross_profit_base_usd_180d: '180G Taban Toplam K/Z USD',
gross_profit_cost_usd_180d: '180G Çıplak Toplam K/Z USD',
gross_profit_base_usd_365d: '360G Taban Toplam K/Z USD',
gross_profit_cost_usd_365d: '360G Çıplak Toplam K/Z USD',
gross_profit_base_usd_total: 'Genel Taban Toplam K/Z USD',
gross_profit_cost_usd_total: 'Genel Çıplak Toplam K/Z USD',
gross_margin_base_90d: '90G Taban Marj',
gross_margin_cost_90d: '90G Çıplak Marj',
gross_margin_base_180d: '180G Taban Marj',
gross_margin_cost_180d: '180G Çıplak Marj',
gross_margin_base_365d: '360G Taban Marj',
gross_margin_cost_365d: '360G Çıplak Marj',
gross_margin_base_total: 'Genel Taban Marj',
gross_margin_cost_total: 'Genel Çıplak Marj',
performance_score_90d: '90G Ürün Skor',
performance_score_180d: '180G Ürün Skor',
performance_score_365d: '360G Ürün Skor',
performance_score_total: 'Genel Ürün Skor',
customer_score_90d: '90G Müşteri Skor',
customer_score_180d: '180G Müşteri Skor',
customer_score_365d: '360G Müşteri Skor',
customer_score_total: 'Genel Müşteri Skor',
market_count_90d: '90G Tekil Piyasa',
customer_count_90d: '90G Tekil Müşteri',
market_count_total: 'Genel Tekil Piyasa',
customer_count_total: 'Genel Tekil Müşteri',
stock_turnover_90d: '90G Yıllık Stok Devir',
stock_turnover_180d: '180G Yıllık Stok Devir',
stock_turnover_365d: '360G Yıllık Stok Devir',
stock_turnover_total: 'Genel Yıllık Stok Devir',
sales_index_90d: '90G Endeks',
sales_index_total: 'Genel Endeks',
stock_days_90d: '90G Stok Gün',
stock_days_180d: '180G Stok Gün',
stock_days_365d: '360G Stok Gün',
stock_days_total: 'Genel Stok Gün',
avg_daily_sales_total: 'Genel Günlük Ort. Adet'
}
function applyMetricLabelOverrides (...columnGroups) {
for (const group of columnGroups) {
for (const col of group || []) {
if (metricLabelOverrides[col?.name]) col.label = metricLabelOverrides[col.name]
}
}
}
applyMetricLabelOverrides(
detailSalesColumns,
columns,
generalColumns,
orderAnalysisColumns,
orderGroupColumns,
orderProductCustomerColumns,
orderMarketDetailColumns,
idleColumns,
marketColumns,
countryColumns,
customerColumns,
salesBreakdownColumns,
productPeriodScoreColumns,
customerPeriodScoreColumns
)
function ensureColumns (targetColumns, columnsToEnsure) {
const existing = new Set(targetColumns.map(col => col.name))
for (const col of columnsToEnsure) {
@@ -2149,6 +2489,36 @@ function removeColumnsInPlace (targetColumns, names) {
return targetColumns
}
function ensureColorYakaColumn (targetColumns) {
const colorIndex = targetColumns.findIndex(col => col.name === 'color_code')
const yakaIndex = targetColumns.findIndex(col => col.name === 'yaka_kodu')
const insertAt = colorIndex >= 0 ? colorIndex : yakaIndex
if (insertAt >= 0 && !targetColumns.some(col => col.name === 'color_yaka')) {
targetColumns.splice(insertAt, 0, {
name: 'color_yaka',
label: 'Renk/Yaka',
field: row => colorYakaValue(row),
align: 'left',
sortable: true,
classes: 'color-yaka-cell',
headerClasses: 'color-yaka-cell'
})
}
removeColumnsInPlace(targetColumns, ['color_code', 'yaka_kodu'])
return targetColumns
}
;[
columns,
generalColumns,
orderAnalysisColumns,
orderGroupColumns,
orderProductCustomerColumns,
orderMarketDetailColumns,
idleColumns,
salesBreakdownColumns
].forEach(ensureColorYakaColumn)
;[
columns,
generalColumns,
@@ -2168,7 +2538,6 @@ function removeColumnsInPlace (targetColumns, names) {
].forEach(target => ensureColumns(target, customerPeriodScoreColumns))
;[
generalColumns,
orderAnalysisColumns,
orderGroupColumns,
orderProductCustomerColumns,
@@ -2200,7 +2569,27 @@ function removeColumnsInPlace (targetColumns, names) {
attachColumnSorts(detailStockColumns)
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(() => [
{ key: 'date', label: 'KPI Tarihi', value: summary.value.kpi_date || '-' },
@@ -2274,13 +2663,16 @@ const visibleOrderAnalysisColumns = computed(() => {
function performanceCardsForRow (row) {
const source = row || {}
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: 'sales90', label: '90G Satış', value: formatNumber(source.sales_qty_90d, 0) },
{ key: 'sales180', label: '180G Satış', value: formatNumber(source.sales_qty_180d, 0) },
{ key: 'markets', label: '90G Piyasa', value: formatNumber(source.market_count_90d, 0) },
{ key: 'customers', label: '90G Müşteri', value: formatNumber(source.customer_count_90d, 0) },
{ key: 'avg', label: 'Ort. USD Satış', value: formatMoney(source.avg_price_usd_90d, 'USD') },
{ key: 'avg', label: 'Ort. Satış Fiyatı', value: formatMoney(source.avg_price_usd_90d, 'USD') },
{ key: 'base', label: 'Taban Maliyet', value: formatMoney(source.base_price_usd, 'USD') },
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(source.cost_price_usd, 'USD') },
{ key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(source.unit_profit_base_90d, 'USD') },
@@ -2304,7 +2696,7 @@ const imageDialogInfoRows = computed(() => {
{ key: 'color', label: 'Renk', value: colorCode || '-' },
{ key: 'yaka', label: 'Yaka', value: yakaCode || '-' },
{ key: 'category', label: 'Kategori', value: productCategoryValue(row) || '-' },
{ key: 'hanger', label: 'Askılı/Yan', value: row.askili_yan || '-' },
{ key: 'hanger', label: 'Askılı/Yan', value: cleanOptionalGroupValue(row.askili_yan) },
{ key: 'first', label: 'İlk Grup', value: productFirstGroupValue(row) || '-' },
{ key: 'main', label: 'Ana Grup', value: productMainGroupValue(row) || '-' },
{ key: 'sub', label: 'Alt Grup', value: row.urun_alt_grubu || '-' },
@@ -2322,27 +2714,26 @@ const groupLevels = [
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' },
{ key: 'yaka_kodu', label: 'Yaka' },
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'market_key', label: 'Piyasa' }
]
const tabGroupLevels = {
products: groupLevels,
sales_color_yaka_market_customer: [
{ 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' },
product_detail: [
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'country', label: 'Ülke' },
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'market_key', label: 'Piyasa' }
],
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: 'customer_segment', label: 'Segment' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' }
{ key: 'customer_name', label: 'Müşteri' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }
],
idle: [
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
@@ -2351,21 +2742,21 @@ const tabGroupLevels = {
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' },
{ key: 'yaka_kodu', label: 'Yaka' }
{ key: 'color_yaka', label: 'Renk/Yaka' }
],
sales_product_country_segment_market_customer: [
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' },
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' },
{ key: 'yaka_kodu', label: 'Yaka' }
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'country', label: 'Ülke' },
{ key: 'customer_segment', label: 'Segment' },
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' }
],
sales_market_customer_product: [
{ key: 'market_key', label: 'Piyasa' },
@@ -2377,10 +2768,11 @@ const tabGroupLevels = {
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' },
{ key: 'yaka_kodu', label: 'Yaka' }
{ key: 'color_yaka', label: 'Renk/Yaka' }
],
sales_country_segment_market_customer_product: [
{ key: 'country', label: 'Ülke' },
{ key: 'customer_segment', label: 'Segment' },
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' },
@@ -2390,21 +2782,19 @@ const tabGroupLevels = {
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' },
{ key: 'yaka_kodu', label: 'Yaka' }
{ key: 'color_yaka', label: 'Renk/Yaka' }
],
order_product_customers: [
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' },
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' },
{ key: 'yaka_kodu', label: 'Yaka' }
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' }
],
order_market_details: [
{ key: 'market_key', label: 'Piyasa' },
@@ -2416,8 +2806,7 @@ const tabGroupLevels = {
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_code', label: 'Renk' },
{ key: 'yaka_kodu', label: 'Yaka' }
{ key: 'color_yaka', label: 'Renk/Yaka' }
]
}
@@ -2443,27 +2832,38 @@ const selectedExpandThroughLevel = computed(() => {
return indexes.length ? Math.max(...indexes) : -1
})
function maxAutoExpandThroughLevelForTab (tabKey = activeTab.value) {
return Number.isFinite(maxAutoExpandLevelByTab[tabKey]) ? maxAutoExpandLevelByTab[tabKey] : 8
}
function maxBackendExpandedKeysForTab (tabKey = activeTab.value) {
if (tabKey === 'product_detail') return 1000
if (tabKey === 'products' || tabKey === 'idle') return 1000
if (salesBreakdownTabKeys.includes(tabKey)) return 1000
return 1000
}
const autoExpandThroughLevel = computed(() => {
const selectedLevel = selectedExpandThroughLevel.value
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(() => ({
products: { rows: rows.value, columns: productColumns.value },
general: { rows: generalRows.value, columns: generalColumns },
order_product_customers: { rows: orderProductCustomerRows.value, columns: orderProductCustomerColumns },
order_market_details: { rows: orderMarketDetailRows.value, columns: orderMarketDetailColumns },
idle: { rows: idleRows.value, columns: idleColumns },
markets: { rows: marketRows.value, columns: marketColumns },
countries: { rows: countryRows.value, columns: countryColumns },
customers: { rows: customerRows.value, columns: customerColumns },
sales_color_yaka_market_customer: { rows: salesBreakdownRows.sales_color_yaka_market_customer, columns: visibleSalesBreakdownColumns.value },
sales_product_country_segment_market_customer: { rows: salesBreakdownRows.sales_product_country_segment_market_customer, columns: visibleSalesBreakdownColumns.value },
sales_market_customer_product: { rows: salesBreakdownRows.sales_market_customer_product, columns: visibleSalesBreakdownColumns.value },
sales_country_segment_market_customer_product: { rows: salesBreakdownRows.sales_country_segment_market_customer_product, columns: visibleSalesBreakdownColumns.value }
}))
const fullExpandThroughLevel = computed(() => {
const lastGroupLevel = Math.max(0, activeGroupLevels.value.length - 2)
return Math.min(lastGroupLevel, maxAutoExpandThroughLevelForTab())
})
const filteredProductRows = computed(() => filterRowsForTable('products', rows.value, productColumns.value))
const isProductKpiTab = computed(() => activeTab.value === 'products' || activeTab.value === 'product_detail')
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 filteredOrderProductCustomerRows = computed(() => filterRowsForTable('order_product_customers', orderProductCustomerRows.value, orderProductCustomerColumns))
const filteredOrderMarketDetailRows = computed(() => filterRowsForTable('order_market_details', orderMarketDetailRows.value, orderMarketDetailColumns))
@@ -2474,6 +2874,7 @@ const filteredCustomerRows = computed(() => filterRowsForTable('customers', cust
const filteredActiveSalesBreakdownRows = computed(() => filterRowsForTable(activeTab.value, salesBreakdownRows[activeTab.value] || [], visibleSalesBreakdownColumns.value))
const activeGroupSourceRows = computed(() => {
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 === 'order_product_customers') return filteredOrderProductCustomerRows.value
if (activeTab.value === 'order_market_details') return filteredOrderMarketDetailRows.value
@@ -2483,10 +2884,26 @@ const activeGroupSourceRows = computed(() => {
const columnFilterOptionMap = computed(() => {
const out = {}
for (const [tableKey, source] of Object.entries(tableFilterSourceMap.value)) {
const tableKey = isProductKpiTab.value ? activeProductTableKey.value : activeTab.value
const source = tableFilterSource(tableKey)
if (!source) return out
out[tableKey] = {}
const preparedOptionEntry = backendGroupedSupportedTab(tableKey)
? backendGroupedFilterOptions.value[tableKey]
: null
const preparedOptions = preparedOptionEntry?._key === backendGroupedFilterOptionsKey(tableKey)
? preparedOptionEntry.options || {}
: {}
for (const col of source.columns) {
if (!isColumnFilterable(col.name)) continue
if (backendGroupedSupportedTab(tableKey) && backendGroupedFilterFields.has(col.name)) {
out[tableKey][col.name] = preparedOptions[col.name] || []
continue
}
if (backendGroupedSupportedTab(tableKey) && !backendGroupedFilterFields.has(col.name)) {
out[tableKey][col.name] = []
continue
}
const seen = new Map()
for (const row of source.rows) {
const value = columnFilterValue(row, col)
@@ -2501,25 +2918,89 @@ const columnFilterOptionMap = computed(() => {
out[tableKey][col.name] = Array.from(seen.values())
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'tr', { numeric: true }))
}
}
return out
})
const productTableRows = computed(() => {
if (shouldUseBackendGroupedRows('products')) return []
const out = []
appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels)
appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels, 'products')
return out
})
const detailProductTableRows = computed(() => shouldUseBackendGroupedRows('product_detail') ? [] : buildGroupedTableRows('product_detail', filteredDetailProductRows.value))
const idleTableRows = computed(() => buildGroupedTableRows('idle', filteredIdleRows.value))
const orderProductCustomerTableRows = computed(() => buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value))
const orderMarketDetailTableRows = computed(() => buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value))
const activeSalesBreakdownTableRows = computed(() => buildGroupedTableRows(activeTab.value, filteredActiveSalesBreakdownRows.value))
const idleTableRows = computed(() => shouldUseBackendGroupedRows('idle') ? [] : buildGroupedTableRows('idle', filteredIdleRows.value))
const orderProductCustomerTableRows = computed(() => shouldUseBackendGroupedRows('order_product_customers') ? [] : buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value))
const orderMarketDetailTableRows = computed(() => shouldUseBackendGroupedRows('order_market_details') ? [] : buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value))
const activeSalesBreakdownTableRows = computed(() => shouldUseBackendGroupedRows(activeTab.value) ? [] : buildGroupedTableRows(activeTab.value, filteredActiveSalesBreakdownRows.value))
const displayProductTableRows = computed(() => backendRowsForTab('products', productTableRows.value))
const displayDetailProductTableRows = computed(() => backendRowsForTab('product_detail', detailProductTableRows.value))
const displayProductKpiTableRows = computed(() => activeTab.value === 'product_detail' ? displayDetailProductTableRows.value : displayProductTableRows.value)
const displayIdleTableRows = computed(() => backendRowsForTab('idle', idleTableRows.value))
const displayOrderProductCustomerTableRows = computed(() => backendRowsForTab('order_product_customers', orderProductCustomerTableRows.value))
const displayOrderMarketDetailTableRows = computed(() => backendRowsForTab('order_market_details', orderMarketDetailTableRows.value))
const displayActiveSalesBreakdownTableRows = computed(() => backendRowsForTab(activeTab.value, activeSalesBreakdownTableRows.value))
const activeDisplayedTableRows = computed(() => {
if (isProductKpiTab.value) return displayProductKpiTableRows.value
if (activeTab.value === 'idle') return displayIdleTableRows.value
if (activeTab.value === 'order_product_customers') return displayOrderProductCustomerTableRows.value
if (activeTab.value === 'order_market_details') return displayOrderMarketDetailTableRows.value
if (salesBreakdownTabKeys.includes(activeTab.value)) return displayActiveSalesBreakdownTableRows.value
if (activeTab.value === 'general') return filteredGeneralRows.value
if (activeTab.value === 'markets') return filteredMarketRows.value
if (activeTab.value === 'countries') return filteredCountryRows.value
if (activeTab.value === 'customers') return filteredCustomerRows.value
return []
})
const detailMainGroupOptions = computed(() => {
if (detailMainGroupOptionRows.value.length) {
return [...detailMainGroupOptionRows.value]
.sort((a, b) => Number(b.sales_qty_90d || 0) - Number(a.sales_qty_90d || 0) || a.label.localeCompare(b.label, 'tr'))
}
const grouped = new Map()
for (const row of rows.value) {
const value = productMainGroupValue(row)
if (!value) continue
const current = grouped.get(value) || { value, label: value, stock_qty: 0, sales_qty_90d: 0, _stockSeen: new Set() }
const variantKey = productVariantMetricKey(row)
if (variantKey && !current._stockSeen.has(variantKey)) {
current._stockSeen.add(variantKey)
current.stock_qty += Number(row?.stock_qty || 0)
} else if (!variantKey) {
current.stock_qty += Number(row?.stock_qty || 0)
}
current.sales_qty_90d += Number(row?.sales_qty_90d || 0)
grouped.set(value, current)
}
return Array.from(grouped.values())
.map(({ _stockSeen, ...option }) => option)
.sort((a, b) => Number(b.sales_qty_90d || 0) - Number(a.sales_qty_90d || 0) || a.label.localeCompare(b.label, 'tr'))
})
function filterRowsBySelectedDetailMainGroup (sourceRows) {
const selected = String(selectedDetailMainGroup.value || '').trim()
const rows = Array.isArray(sourceRows) ? sourceRows : []
if (!selected) return []
return rows.filter(row => {
const value = productMainGroupValue(row)
if (value === selected) return true
return Boolean(row?.__group && row.group_field !== 'urun_ana_grubu' && !value)
})
}
function ensureDetailMainGroupSelection () {
const options = detailMainGroupOptions.value
if (!options.length) {
selectedDetailMainGroup.value = ''
return false
}
const preferred = options.find(option => String(option.value || '').toLocaleUpperCase('tr-TR') === 'TAKIM ELBISE')
if (!selectedDetailMainGroup.value || !options.some(option => option.value === selectedDetailMainGroup.value)) {
selectedDetailMainGroup.value = (preferred || options[0]).value
}
return true
}
const productGroupKeys = computed(() => {
const keys = []
@@ -2534,13 +3015,16 @@ const productAutoExpandKeys = computed(() => {
})
const allProductGroupsExpanded = computed(() => {
const keys = productAutoExpandKeys.value
return keys.length > 0 && keys.every(key => expandedGroups.value[key] === true)
return selectedExpandThroughLevel.value >= fullExpandThroughLevel.value
})
function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels) {
function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels, tableKey = activeTab.value) {
appendGroupRowsAt(out, sourceRows, level, 0, parentKeys, levels, tableKey)
}
function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, levels = groupLevels, tableKey = activeTab.value) {
if (out.length >= maxRenderedGroupedRows) return
if (level >= levels.length) {
out.push(...sortProductLeafRows(sourceRows))
return
}
@@ -2553,12 +3037,17 @@ function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLeve
}
Array.from(grouped.entries())
.sort((a, b) => a[0].localeCompare(b[0], 'tr'))
.sort((a, b) => compareGroupedBucketsForTable(tableKey, a, b, groupDef))
.forEach(([value, groupRows]) => {
if (out.length >= maxRenderedGroupedRows) return
if (shouldSkipGroupValue(groupDef.key, value)) {
appendGroupRowsAt(out, groupRows, level + 1, visualLevel, parentKeys, levels, tableKey)
return
}
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
out.push(makeGroupRow(key, level, groupDef, value, groupRows))
out.push(makeGroupRow(key, visualLevel, groupDef, value, groupRows))
if (isGroupExpanded(key)) {
appendGroupRows(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`], levels)
appendGroupRowsAt(out, groupRows, level + 1, visualLevel + 1, [...parentKeys, `${groupDef.key}:${value}`], levels, tableKey)
}
})
}
@@ -2566,14 +3055,160 @@ function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLeve
function buildGroupedTableRows (tableKey, sourceRows) {
const levels = tabGroupLevels[tableKey] || groupLevels
const out = []
appendGroupRows(out, sourceRows, 0, [`tab:${tableKey}`], levels)
appendGroupRows(out, sourceRows, 0, [`tab:${tableKey}`], levels, tableKey)
return out
}
function backendRowsForTab (tabKey, fallbackRows) {
if (hasActiveTableFilters(tabKey)) return fallbackRows
if (shouldUseBackendGroupedRows(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) {
@@ -2592,6 +3227,10 @@ function collectGroupKeys (out, sourceRows, level, parentKeys, levels = groupLev
.sort((a, b) => a[0].localeCompare(b[0], 'tr'))
.forEach(([value, groupRows]) => {
if (out.length >= maxBulkExpandKeys) return
if (shouldSkipGroupValue(groupDef.key, value)) {
collectGroupKeys(out, groupRows, level + 1, parentKeys, levels, maxLevel)
return
}
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
out.push(key)
if (level < maxLevel) {
@@ -2610,9 +3249,150 @@ function sortProductLeafRows (sourceRows) {
})
}
function sortLeafRowsForTable (tableKey, sourceRows) {
const { sortBy, descending } = tableSortState(tableKey)
if (!sortBy) return sortProductLeafRows(sourceRows)
return sortFlatTableRows(sourceRows, sortBy, descending)
}
function tableSortState (tableKey) {
const pagination = tablePagination[tableKey] || {}
return {
sortBy: pagination.sortBy || '',
descending: pagination.descending !== false
}
}
function compareGroupedBucketsForTable (tableKey, left, right, groupDef) {
const [leftLabel, leftRows] = left
const [rightLabel, rightRows] = right
const { sortBy, descending } = tableSortState(tableKey)
if (!sortBy || sortBy === groupDef.key) {
return String(leftLabel).localeCompare(String(rightLabel), 'tr', { numeric: true, sensitivity: 'base' })
}
const leftValue = groupedBucketSortValue(leftRows, sortBy)
const rightValue = groupedBucketSortValue(rightRows, sortBy)
const cmp = compareColumnSortValues(leftValue, rightValue)
if (cmp !== 0) return descending ? -cmp : cmp
return String(leftLabel).localeCompare(String(rightLabel), 'tr', { numeric: true, sensitivity: 'base' })
}
function groupedBucketSortValue (sourceRows, sortBy) {
if (!sourceRows?.length) return 0
if (shouldUseDerivedGroupSortValue(sortBy)) {
return sortValueForColumnName(aggregateGroupFields(sourceRows), sortBy)
}
if (sortBy === 'stock_qty') return distinctVariantStockQty(sourceRows)
if (sortBy === 'idle_cost_usd') return distinctVariantStockCost(sourceRows)
if (sortBy === 'base_price_usd' || sortBy === 'cost_price_usd') return weightedAverageProductCost(sourceRows, sortBy)
if (shouldSumField(sortBy)) return sumRows(sourceRows, sortBy)
if (shouldAverageField(sortBy)) return weightedAverageOrAverage(sourceRows, sortBy, weightFieldForMetric(sortBy))
return sortValueForColumnName(sourceRows[0], sortBy)
}
function shouldUseDerivedGroupSortValue (sortBy) {
return /^(performance_score|stock_turnover|stock_days|avg_price_usd|unit_profit|gross_profit|gross_margin|market_count|customer_count|sales_index)/i.test(String(sortBy || ''))
}
function sortProductGroupedTableRows (tableRows, sortBy, descending) {
const source = Array.isArray(tableRows) ? tableRows : []
if (backendGroupedSupportedTab(activeTab.value)) return source
if (!sortBy || !source.some(row => row?.__group)) {
return sortFlatTableRows(source, sortBy, descending)
}
const roots = []
const stack = []
for (const row of source) {
const node = { row, children: [] }
if (row?.__group) {
const level = Math.max(0, Number(row.level || 0))
stack.length = Math.min(stack.length, level)
const parent = level > 0 ? stack[level - 1] : null
if (parent) parent.children.push(node)
else roots.push(node)
stack[level] = node
stack.length = level + 1
} else {
const parent = stack[stack.length - 1]
if (parent) parent.children.push(node)
else roots.push(node)
}
}
const sorted = []
const appendNodes = (nodes) => {
const ordered = sortNodeSiblings(nodes, sortBy, descending)
for (const node of ordered) {
sorted.push(node.row)
if (node.children.length) appendNodes(node.children)
}
}
appendNodes(roots)
return sorted
}
function sortNodeSiblings (nodes, sortBy, descending) {
const source = mergeDuplicateGroupSiblingNodes(nodes)
if (!sortBy) return source
return source.sort((a, b) => {
const cmp = compareColumnSortValues(sortValueForColumnName(a.row, sortBy), sortValueForColumnName(b.row, sortBy))
return descending ? -cmp : cmp
})
}
function mergeDuplicateGroupSiblingNodes (nodes) {
const out = []
const seen = new Map()
for (const node of nodes) {
if (!node?.row?.__group) {
out.push(node)
continue
}
const key = [
Number(node.row.level || 0),
String(node.row.group_field || ''),
String(node.row.group_value || node.row.label || '')
].join('|')
const existing = seen.get(key)
if (existing) {
existing.children.push(...node.children)
continue
}
seen.set(key, node)
out.push(node)
}
return out
}
function sortFlatTableRows (tableRows, sortBy, descending) {
if (!sortBy) return [...tableRows]
return [...tableRows].sort((a, b) => {
const cmp = compareColumnSortValues(sortValueForColumnName(a, sortBy), sortValueForColumnName(b, sortBy))
return descending ? -cmp : cmp
})
}
function sortValueForColumnName (row, columnName) {
const columns = [
...(productColumns.value || []),
...idleColumns,
...orderProductCustomerColumns,
...orderMarketDetailColumns,
...(visibleSalesBreakdownColumns.value || [])
]
const col = columns.find(item => item.name === columnName || item.field === columnName) || { name: columnName, field: columnName }
return sortValueForColumn(row, col)
}
function makeGroupRow (key, level, groupDef, value, groupRows) {
const imageSource = groupRows.find(row => row?.product_code) || {}
const aggregate = aggregateGroupFields(groupRows, groupDef.key)
const colorSummary = groupDef.key === 'color_code' ? value : ''
const yakaSummary = groupDef.key === 'yaka_kodu' ? value : ''
const colorDescriptionSummary = groupDef.key === 'color_code' ? distinctSummary(groupRows, 'color_description') : ''
return {
__group: true,
row_key: `group|${key}`,
@@ -2620,25 +3400,27 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
level,
group_field: groupDef.key,
group_value: value,
label: value,
label: colorYakaGroupLabel(groupDef.key, value, colorSummary, yakaSummary),
count: groupRows.length,
image_product_code: imageSource.product_code || '',
image_color_code: imageSource.color_code || '',
image_color_description: imageSource.color_description || '',
image_yaka_kodu: imageSource.yaka_kodu || '',
product_code: groupDef.key === 'product_code' ? value : distinctSummary(groupRows, 'product_code'),
color_code: groupDef.key === 'color_code' ? value : distinctSummary(groupRows, 'color_code'),
yaka_kodu: groupDef.key === 'yaka_kodu' ? value : distinctSummary(groupRows, 'yaka_kodu'),
item_description: distinctSummary(groupRows, 'item_description'),
kategori: groupDef.key === 'kategori' ? value : distinctSummary(groupRows, 'kategori'),
urun_ilk_grubu: groupDef.key === 'urun_ilk_grubu' ? value : distinctSummary(groupRows, 'urun_ilk_grubu'),
askili_yan: groupDef.key === 'askili_yan' ? value : distinctSummary(groupRows, 'askili_yan'),
urun_ana_grubu: groupDef.key === 'urun_ana_grubu' ? value : distinctSummary(groupRows, 'urun_ana_grubu'),
urun_alt_grubu: groupDef.key === 'urun_alt_grubu' ? value : distinctSummary(groupRows, 'urun_alt_grubu'),
market_key: groupDef.key === 'market_key' ? value : distinctSummary(groupRows, 'market_key'),
country: groupDef.key === 'country' ? value : distinctSummary(groupRows, 'country'),
customer_segment: groupDef.key === 'customer_segment' ? value : distinctSummary(groupRows, 'customer_segment'),
customer_code: groupDef.key === 'customer_code' ? value : distinctSummary(groupRows, 'customer_code'),
customer_name: groupDef.key === 'customer_name' ? value : distinctSummary(groupRows, 'customer_name'),
product_code: groupDef.key === 'product_code' ? value : '',
color_code: colorSummary,
color_description: colorDescriptionSummary,
yaka_kodu: yakaSummary,
item_description: groupDef.key === 'item_description' ? value : '',
kategori: groupDef.key === 'kategori' ? value : '',
urun_ilk_grubu: groupDef.key === 'urun_ilk_grubu' ? value : '',
askili_yan: groupDef.key === 'askili_yan' ? value : '',
urun_ana_grubu: groupDef.key === 'urun_ana_grubu' ? value : '',
urun_alt_grubu: groupDef.key === 'urun_alt_grubu' ? value : '',
market_key: groupDef.key === 'market_key' ? value : '',
country: groupDef.key === 'country' ? value : '',
customer_segment: groupDef.key === 'customer_segment' ? value : '',
customer_code: groupDef.key === 'customer_code' ? value : '',
customer_name: groupDef.key === 'customer_name' ? value : '',
stock_qty: sumRows(groupRows, 'stock_qty'),
sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'),
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
@@ -2651,16 +3433,16 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
gross_profit_usd_90d: sumRows(groupRows, 'gross_profit_usd_90d'),
gross_profit_usd_180d: sumRows(groupRows, 'gross_profit_usd_180d'),
gross_profit_usd_total: sumRows(groupRows, 'gross_profit_usd_total'),
market_count_90d: distinctCount(groupRows, 'market_key'),
customer_count_90d: sumRows(groupRows, 'customer_count_90d'),
market_count_total: sumRows(groupRows, 'market_count_total'),
customer_count_total: sumRows(groupRows, 'customer_count_total'),
market_count_90d: distinctSpreadCount(groupRows, 'market_count_90d'),
customer_count_90d: distinctSpreadCount(groupRows, 'customer_count_90d'),
market_count_total: distinctSpreadCount(groupRows, 'market_count_total'),
customer_count_total: distinctSpreadCount(groupRows, 'customer_count_total'),
avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'),
avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'),
avg_price_usd_365d: weightedAverage(groupRows, 'sales_usd_365d', 'sales_qty_365d'),
avg_price_usd_total: weightedAverage(groupRows, 'sales_usd_total', 'sales_qty_total'),
base_price_usd: weightedAverageOrAverage(groupRows, 'base_price_usd', 'sales_qty_90d'),
cost_price_usd: weightedAverageOrAverage(groupRows, 'cost_price_usd', 'sales_qty_90d'),
base_price_usd: weightedAverageProductCost(groupRows, 'base_price_usd'),
cost_price_usd: weightedAverageProductCost(groupRows, 'cost_price_usd'),
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_base_180d: weightedAverageOrAverage(groupRows, 'unit_profit_base_180d', 'sales_qty_180d'),
@@ -2682,8 +3464,10 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
sales_index_total: averageRows(groupRows, 'sales_index_total'),
performance_bucket: dominantValue(groupRows, 'performance_bucket'),
...aggregate,
performance_score: groupPerformanceScore(aggregate, groupDef.key),
recommendation: `${formatNumber(groupRows.length, 0)} satır`
performance_score: Object.prototype.hasOwnProperty.call(aggregate, 'performance_score')
? aggregate.performance_score
: groupPerformanceScore(aggregate, groupDef.key),
recommendation: dominantValue(groupRows, 'recommendation') || `${formatNumber(groupRows.length, 0)} satır`
}
}
@@ -2696,14 +3480,23 @@ function aggregateGroupFields (sourceRows, groupField = '') {
for (const field of fields) {
if (field === 'row_key' || field === 'key') continue
if (field === 'stock_qty') {
out[field] = distinctVariantStockQty(sourceRows)
if (/^gross_margin(_base|_cost)?(_|$)/i.test(field)) continue
if (field === 'stock_qty' || /^avg_stock_/i.test(field)) {
out[field] = distinctVariantNumber(sourceRows, field)
continue
}
if (field === 'idle_cost_usd') {
out[field] = distinctVariantStockCost(sourceRows)
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)) {
out[field] = sumRows(sourceRows, field)
} else if (shouldAverageField(field)) {
@@ -2716,15 +3509,18 @@ function aggregateGroupFields (sourceRows, groupField = '') {
}
function shouldSumField (field) {
return /(_qty|_usd|_count|_value_usd|line_count|invoice_count|order_count|product_count|market_count|customer_count|overdue_qty|stock_qty|net_stock_after_order|idle_cost_usd)$/i.test(field)
return /^(sales_qty_|sales_usd_|avg_daily_sales_|gross_profit_|invoice_count_|market_count_|customer_count_|product_group_count_|product_count_)/i.test(field) ||
/(_qty|_usd|_count|_value_usd|line_count|invoice_count|order_count|product_count|market_count|customer_count|overdue_qty|stock_qty|net_stock_after_order|idle_cost_usd)$/i.test(field)
}
function shouldAverageField (field) {
return /^(avg_|unit_|base_price|cost_price|gross_margin|expected_margin|sales_index|performance_score|stock_days|stock_turnover)/i.test(field) ||
if (/^avg_daily_sales_/i.test(field)) return false
return /^(avg_|unit_|base_price|cost_price|gross_margin|expected_margin|sales_index|performance_score|customer_score|stock_days|stock_turnover)/i.test(field) ||
/(_price_usd|_margin|_index|_days)$/i.test(field)
}
function weightFieldForMetric (field) {
if (field.startsWith('stock_turnover')) return `sales_qty_${countPeriodSuffix(field)}`
if (field.includes('_180d')) return 'sales_qty_180d'
if (field.includes('_365d')) return 'sales_qty_365d'
if (field.includes('_total')) return 'sales_qty_total'
@@ -2734,21 +3530,65 @@ function weightFieldForMetric (field) {
}
function applyDerivedGroupMetrics (out, sourceRows, groupField = '') {
Object.assign(out, normalizeProductCostFields(out))
const isGroup = Boolean(String(groupField || '').trim())
const preservedCustomerScores = {}
const preservedProductScores = {}
const preservedStockTurnovers = {}
for (const suffix of ['90d', '180d', '365d', 'total']) {
if (Object.prototype.hasOwnProperty.call(out, `customer_score_${suffix}`)) {
preservedCustomerScores[suffix] = Number(out[`customer_score_${suffix}`] || 0)
}
if (Object.prototype.hasOwnProperty.call(out, `performance_score_${suffix}`)) {
preservedProductScores[suffix] = Number(out[`performance_score_${suffix}`] || 0)
}
if (isGroup && Object.prototype.hasOwnProperty.call(out, `stock_turnover_${suffix}`)) {
preservedStockTurnovers[suffix] = Number(out[`stock_turnover_${suffix}`] || 0)
}
}
if (
Object.prototype.hasOwnProperty.call(out, 'performance_score') &&
!Object.prototype.hasOwnProperty.call(preservedProductScores, '90d')
) {
preservedProductScores['90d'] = Number(out.performance_score || 0)
}
for (const suffix of ['90d', '180d', '365d', 'total']) {
const sales = Number(out[`sales_usd_${suffix}`] || 0)
const qty = Number(out[`sales_qty_${suffix}`] || 0)
const stockQty = Number(out.stock_qty || 0)
const avgStock = Number(out[`avg_stock_${suffix}`] || 0)
const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty)
const days = productPerformancePeriodDays(out, suffix)
const avgDaily = days > 0 ? qty / days : Number(out[`avg_daily_sales_${suffix}`] || 0)
if (days > 0) out[`avg_daily_sales_${suffix}`] = avgDaily
if (avgDaily > 0 && turnoverBase > 0) out[`stock_days_${suffix}`] = turnoverBase / avgDaily
else if (qty <= 0 && stockQty > 0) out[`stock_days_${suffix}`] = 9999
else if (!Object.prototype.hasOwnProperty.call(out, `stock_days_${suffix}`)) out[`stock_days_${suffix}`] = 0
out[`stock_turnover_${suffix}`] = annualizedStockTurnover(qty, turnoverBase, days)
if (Object.prototype.hasOwnProperty.call(preservedStockTurnovers, suffix)) {
out[`stock_turnover_${suffix}`] = preservedStockTurnovers[suffix]
}
if (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty
if (sales > 0) {
if (out[`gross_profit_base_usd_${suffix}`] !== undefined) {
out[`gross_margin_base_${suffix}`] = Number(out[`gross_profit_base_usd_${suffix}`] || 0) / sales
}
if (out[`gross_profit_cost_usd_${suffix}`] !== undefined) {
out[`gross_margin_cost_${suffix}`] = Number(out[`gross_profit_cost_usd_${suffix}`] || 0) / sales
}
if (out[`gross_profit_usd_${suffix}`] !== undefined) {
out[`gross_margin_${suffix}`] = Number(out[`gross_profit_usd_${suffix}`] || 0) / sales
}
const { 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)
@@ -2766,15 +3606,33 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') {
if (sourceRows.some(row => row.performance_bucket)) {
out.performance_bucket = dominantValue(sourceRows, 'performance_bucket')
}
out.customer_score_90d = customerSalesPeriodScore(periodMetricSource(out, '90d'))
out.customer_score_180d = customerSalesPeriodScore(periodMetricSource(out, '180d'))
out.customer_score_365d = customerSalesPeriodScore(periodMetricSource(out, '365d'))
out.customer_score_total = customerSalesPeriodScore(periodMetricSource(out, 'total'))
out.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(out, '90d'))
out.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(out, '180d'))
out.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(out, '365d'))
out.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(out, 'total'))
out.performance_score = groupPerformanceScore(out, groupField)
for (const suffix of ['90d', '180d', '365d', 'total']) {
out[`customer_score_${suffix}`] = Object.prototype.hasOwnProperty.call(preservedCustomerScores, suffix)
? preservedCustomerScores[suffix]
: customerSalesPeriodScore(periodMetricSource(out, suffix))
out[`performance_score_${suffix}`] = Object.prototype.hasOwnProperty.call(preservedProductScores, suffix)
? preservedProductScores[suffix]
: productSalesPeriodScore(productPeriodMetricSource(out, suffix))
}
out.performance_score = Number(out.performance_score_90d || 0)
}
function productPerformancePeriodDays (row, suffix) {
if (suffix === '90d') return 90
if (suffix === '180d') return 180
if (suffix === '365d') return 360
if (suffix !== 'total') return 0
const start = parseProductPerformanceDate(row?.period_start) || new Date(Date.UTC(2022, 0, 1))
const end = parseProductPerformanceDate(row?.period_end) || parseProductPerformanceDate(row?.kpi_date)
if (!start || !end || end < start) return 0
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
}
function parseProductPerformanceDate (value) {
const text = String(value || '').trim().slice(0, 10)
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return null
const [year, month, day] = text.split('-').map(Number)
return new Date(Date.UTC(year, month - 1, day))
}
function groupPerformanceScore (row, groupField = '') {
@@ -2788,32 +3646,39 @@ function salesGroupPerformanceScore (row) {
}
function productSalesPeriodScore (row) {
const salesIndex = Number(row?.sales_index_90d || 0)
const margin = Number(row?.gross_margin_cost_90d ?? row?.gross_margin_90d ?? 0)
const invoices = Number(row?.invoice_count_90d || 0)
const qty = Number(row?.sales_qty_90d || 0)
let stockTurnover = Number(row?.stock_turnover_90d || 0)
const stockQty = Number(row?.stock_qty || 0)
if (!stockTurnover && stockQty > 0) stockTurnover = qty / stockQty
return Math.min(35, Math.max(0, salesIndex) * 18) +
Math.min(30, Math.max(0, margin) * 60) +
Math.min(15, invoices * 1.5) +
Math.min(10, qty / 10) +
Math.min(10, Math.max(0, stockTurnover) * 5)
const suffix = row?.suffix || '90d'
const periodDays = productPerformancePeriodDays(row, suffix)
return productScore100({
suffix,
periodDays,
salesUSD: Number(row?.sales_usd_90d || 0),
salesIndex: Number(row?.sales_index_90d || 0),
margin: Number(row?.gross_margin_cost_90d ?? row?.gross_margin_90d ?? 0),
stockTurnover: Number(row?.stock_turnover_90d || 0),
marketCount: Number(row?.market_count_90d || 0),
customerCount: Number(row?.customer_count_90d || 0)
})
}
function productPeriodMetricSource (row, suffix) {
const salesUSD = Number(row?.[`sales_usd_${suffix}`] || 0)
const salesIndex = Number(row?.[`sales_index_${suffix}`] || 0) || (salesUSD > 0 ? salesUSD / 1000 : 0)
const salesIndex = Number(row?.[`sales_index_${suffix}`] || 0)
const qty = Number(row?.[`sales_qty_${suffix}`] || 0)
const stockQty = Number(row?.stock_qty || 0)
const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0)
const isGroup = Boolean(row?.__group || String(row?.group_field || '').trim())
const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty)
const turnoverKey = `stock_turnover_${suffix}`
const hasTurnover = Object.prototype.hasOwnProperty.call(row || {}, turnoverKey)
return {
suffix,
sales_index_90d: salesIndex,
sales_usd_90d: Number(row?.[`sales_usd_${suffix}`] || 0),
gross_margin_cost_90d: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
gross_margin_90d: Number(row?.[`gross_margin_${suffix}`] ?? 0),
invoice_count_90d: Number(row?.[`invoice_count_${suffix}`] || 0),
sales_qty_90d: qty,
stock_turnover_90d: Number(row?.[`stock_turnover_${suffix}`] || 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
}
}
@@ -2832,29 +3697,131 @@ function customerSalesGroupPerformanceScore (row) {
}
function customerSalesPeriodScore (row) {
const salesUSD = Number(row?.sales_usd || 0)
const margin = Number(row?.gross_margin_cost ?? row?.gross_margin ?? 0)
const invoices = Number(row?.invoice_count || 0)
const qty = Number(row?.sales_qty || 0)
const productCount = Number(row?.product_count || 0)
return Math.min(35, salesUSD / 1000) +
Math.min(25, Math.max(0, margin) * 55) +
Math.min(20, invoices * 2) +
Math.min(10, qty / 10) +
Math.min(10, productCount)
return customerScore100({
suffix: row?.suffix || '90d',
salesUSD: Number(row?.sales_usd || 0),
margin: Number(row?.gross_margin_cost ?? row?.gross_margin ?? 0),
productGroupCount: Number(row?.product_group_count || row?.product_count || 0),
salesQty: Number(row?.sales_qty || 0)
})
}
function periodMetricSource (row, suffix) {
return {
suffix,
sales_usd: Number(row?.[`sales_usd_${suffix}`] || 0),
gross_margin_cost: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
gross_margin: Number(row?.[`gross_margin_${suffix}`] ?? 0),
invoice_count: Number(row?.[`invoice_count_${suffix}`] || 0),
sales_qty: Number(row?.[`sales_qty_${suffix}`] || 0),
product_group_count: periodCount(row, 'product_group_count', suffix),
product_count: Number(row?.product_count || 0)
}
}
function productScore100 ({ suffix = '90d', periodDays = 0, salesUSD = 0, margin = 0, stockTurnover = 0, marketCount = 0, customerCount = 0 } = {}) {
if (Number(salesUSD || 0) <= 0) return 1
const revenue = ratioScore(salesUSD, productRevenueTarget(suffix, periodDays))
return clampScore(
0.30 * marginScore(margin) +
0.20 * ratioScore(stockTurnover, stockTurnoverTarget(suffix)) +
0.20 * revenue +
0.05 * ratioScore(marketCount, marketSpreadTarget(suffix)) +
0.25 * ratioScore(customerCount, customerSpreadTarget(suffix, periodDays))
)
}
function customerScore100 ({ suffix = '90d', salesUSD = 0, margin = 0, productGroupCount = 0, salesQty = 0 } = {}) {
if (Number(salesUSD || 0) <= 0) return 1
return clampScore(
0.35 * ratioScore(salesUSD, customerRevenueTarget(suffix)) +
0.30 * marginScore(margin) +
0.20 * ratioScore(productGroupCount, 8) +
0.15 * ratioScore(salesQty, customerQtyTarget(suffix))
)
}
function ratioScore (value, target) {
const n = Number(value || 0)
const t = Number(target || 0)
if (!Number.isFinite(n) || !Number.isFinite(t) || n <= 0 || t <= 0) return 0
return Math.min(100, Math.max(0, n) * 100 / t)
}
function marginScore (margin) {
return ratioScore(Math.max(0, Number(margin || 0)), 0.45)
}
function clampScore (score) {
const n = Number(score || 0)
if (!Number.isFinite(n) || n <= 0) return 0
return Math.round(Math.min(100, n) * 10000) / 10000
}
function productRevenueTarget (suffix, periodDays = 0) {
if (suffix === '180d') return 50000
if (suffix === '365d') return 100000
if (suffix === 'total') return 50000 * totalPeriodMultiplier(periodDays)
return 25000
}
function customerRevenueTarget (suffix) {
if (suffix === '180d') return 50000
if (suffix === '365d') return 100000
if (suffix === 'total') return 300000
return 25000
}
function customerQtyTarget (suffix) {
if (suffix === '180d') return 1000
if (suffix === '365d') return 2000
if (suffix === 'total') return 6000
return 500
}
const STOCK_TURNOVER_YEAR_DAYS = 360
function annualizedStockTurnover (salesQty, avgStock, periodDays) {
const qty = Number(salesQty || 0)
const stock = Number(avgStock || 0)
const days = Number(periodDays || 0)
if (!Number.isFinite(qty) || !Number.isFinite(stock) || qty <= 0 || stock <= 0) return 0
const raw = qty / stock
return days > 0 ? raw * STOCK_TURNOVER_YEAR_DAYS / days : raw
}
function stockTurnoverTarget () {
return 4
}
function marketSpreadTarget (suffix) {
if (suffix === '90d') return 3
if (suffix === '180d') return 3
if (suffix === '365d') return 6
return 6
}
function customerSpreadTarget (suffix, periodDays = 0) {
if (suffix === '90d') return 8
if (suffix === '180d') return 20
if (suffix === '365d') return 50
return 20 * totalPeriodMultiplier(periodDays)
}
function totalPeriodMultiplier (periodDays = 0) {
let days = Number(periodDays || 0)
if (!Number.isFinite(days) || days <= 0) {
days = productPerformancePeriodDays({ kpi_date: new Date().toISOString().slice(0, 10) }, 'total')
}
return Math.max(1, days / 180)
}
function periodCount (row, prefix, suffix) {
const keyed = Number(row?.[`${prefix}_${suffix}`] || 0)
if (keyed > 0) return keyed
if (suffix === 'total') return Number(row?.[`${prefix}_total`] || 0)
return Number(row?.[`${prefix}_90d`] || row?.[prefix] || 0)
}
function orderGroupPerformanceScore (row) {
const orderUSD = Number(row?.order_usd || 0)
const margin = Number(row?.expected_margin_cost || 0)
@@ -2873,27 +3840,31 @@ function sumRows (sourceRows, field) {
}
function productVariantStockKey (row) {
const productCode = String(row?.product_code || row?.image_product_code || '').trim()
const colorCode = String(row?.color_code || row?.image_color_code || '').trim()
const yakaKodu = String(row?.yaka_kodu || row?.image_yaka_kodu || '').trim()
const productCode = String(row?.product_code || '').trim()
const colorCode = String(row?.color_code || '').trim()
const yakaKodu = String(row?.yaka_kodu || '').trim()
if (!productCode && !colorCode && !yakaKodu) return ''
return `${productCode}|${colorCode}|${yakaKodu}`
}
function distinctVariantStockQty (sourceRows) {
return distinctVariantNumber(sourceRows, 'stock_qty')
}
function distinctVariantNumber (sourceRows, field) {
const seen = new Set()
let total = 0
let keyed = false
for (const row of sourceRows) {
const key = productVariantStockKey(row)
const stockQty = Number(row?.stock_qty || 0)
const value = Number(row?.[field] || 0)
if (!key) continue
keyed = true
if (seen.has(key)) continue
seen.add(key)
total += stockQty
total += value
}
return keyed ? total : sumRows(sourceRows, 'stock_qty')
return keyed ? total : sumRows(sourceRows, field)
}
function distinctVariantStockCost (sourceRows) {
@@ -2922,6 +3893,83 @@ function distinctCount (sourceRows, field) {
return values.size
}
function isDistinctSpreadCountField (field) {
return /^market_count(_|$)/i.test(field) || /^customer_count(_|$)/i.test(field)
}
function countPeriodSuffix (field) {
if (field.includes('_180d')) return '180d'
if (field.includes('_365d')) return '365d'
if (field.includes('_total')) return 'total'
return '90d'
}
function salesUsdFieldForSuffix (suffix) {
return `sales_usd_${suffix}`
}
function distinctSpreadCount (sourceRows, field) {
const suffix = countPeriodSuffix(field)
return /^market_count/i.test(field)
? distinctRevenueMarketCount(sourceRows, suffix, field)
: distinctRevenueCustomerCount(sourceRows, suffix, field)
}
function distinctRevenueMarketCount (sourceRows, suffix, fallbackField) {
const salesField = salesUsdFieldForSuffix(suffix)
const values = new Set()
for (const row of sourceRows) {
if (Number(row?.[salesField] || 0) <= 0) continue
const marketKeys = stringListFromRow(row, `__market_keys_${suffix}`)
for (const key of marketKeys) {
const value = displayMarketName(key)
if (value && value !== 'STOK' && value !== '-') values.add(value)
}
if (marketKeys.length > 0) continue
const value = displayMarketName(row?.market_key)
if (value && value !== 'STOK' && value !== '-') values.add(value)
}
if (values.size > 0) return values.size
return sourceRows.reduce((sum, row) => {
return Number(row?.[salesField] || 0) > 0 ? sum + Number(row?.[fallbackField] || 0) : sum
}, 0)
}
function distinctRevenueCustomerCount (sourceRows, suffix, fallbackField) {
const salesField = salesUsdFieldForSuffix(suffix)
const values = new Set()
for (const row of sourceRows) {
if (Number(row?.[salesField] || 0) <= 0) continue
const customerKeys = stringListFromRow(row, `__customer_keys_${suffix}`)
for (const code of customerKeys) {
if (code && code !== '-') values.add(code)
}
if (customerKeys.length > 0) continue
const code = String(row?.customer_code || '').trim()
if (code && code !== '-') values.add(code)
}
if (values.size > 0) return values.size
return sourceRows.reduce((sum, row) => {
return Number(row?.[salesField] || 0) > 0 ? sum + Number(row?.[fallbackField] || 0) : sum
}, 0)
}
function stringListFromRow (row, field) {
const value = row?.[field]
if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean)
const text = String(value || '').trim()
if (!text) return []
if (text.startsWith('[')) {
try {
const parsed = JSON.parse(text)
return Array.isArray(parsed) ? parsed.map(item => String(item || '').trim()).filter(Boolean) : []
} catch {
return []
}
}
return [text]
}
function distinctSummary (sourceRows, field, fallbackField) {
const values = new Set()
for (const row of sourceRows) {
@@ -2970,6 +4018,9 @@ function weightedAverage (sourceRows, amountField, qtyField) {
}
function weightedAverageOrAverage (sourceRows, valueField, qtyField) {
if (/^(performance_score|customer_score)/i.test(valueField)) {
return weightedAverageScore(sourceRows, valueField, scoreSuffixForMetric(valueField))
}
const weightedRows = sourceRows
.map(row => ({
value: Number(row[valueField] || 0),
@@ -2980,7 +4031,61 @@ function weightedAverageOrAverage (sourceRows, valueField, qtyField) {
if (qty > 0) {
return weightedRows.reduce((sum, row) => sum + row.value * row.qty, 0) / qty
}
return averageRows(sourceRows, valueField)
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) {
@@ -2999,59 +4104,132 @@ function marginFromRows (sourceRows, salesField, qtyField, unitCostField) {
}
function normalizeGroupValue (value) {
const text = String(value || '').trim()
return text || '-'
return String(value || '').trim() || '(Boş)'
}
function shouldSkipGroupValue (field, value) {
return false
}
function groupCanExpand (rowOrKey) {
const key = typeof rowOrKey === 'string' ? rowOrKey : rowOrKey?.key
const depth = groupKeyDepth(key)
const tabKey = tabKeyFromGroupKey(key)
const levels = tabGroupLevels[tabKey] || groupLevels
return depth >= 0 && depth < levels.length - 1
}
function isGroupExpanded (key) {
return expandedGroups.value[key] === true
if (!groupCanExpand(key)) return false
const tabKey = tabKeyFromGroupKey(key)
const depth = groupKeyDepth(key)
const level = selectedExpandThroughLevelForTab(tabKey)
return depth >= 0 && level >= depth
}
function toggleGroup (key) {
lastManualExpandedGroupKey.value = key
expandedGroups.value = {
...expandedGroups.value,
[key]: !isGroupExpanded(key)
}
scheduleLoadBackendGroupedRows()
function groupKeyDepth (key) {
const parts = String(key || '').split('|').filter(Boolean)
return Math.max(-1, parts.length - 2)
}
function toggleAllProductGroups () {
const keys = productAutoExpandKeys.value
if (allProductGroupsExpanded.value) {
collapseAllProductGroups()
return
}
expandSelectedProductGroups()
function tabKeyFromGroupKey (key) {
const first = String(key || '').split('|')[0] || ''
return first.startsWith('tab:') ? first.slice(4) : activeTab.value
}
function collapseAllProductGroups () {
expandedGroups.value = {}
lastManualExpandedGroupKey.value = ''
scheduleLoadBackendGroupedRows()
function selectedExpandThroughLevelForTab (tabKey) {
const levels = tabGroupLevels[tabKey] || groupLevels
const selected = new Set(selectedExpandLevelKeysByTab[tabKey] || [])
const indexes = levels
.map((level, index) => selected.has(level.key) ? index : -1)
.filter(index => index >= 0)
return indexes.length ? Math.max(...indexes) : -1
}
function expandSelectedProductGroups () {
const keys = productAutoExpandKeys.value
const expandableKeys = keys.slice(0, maxBulkExpandKeys)
const tabPrefix = `tab:${activeTab.value}|`
function activeTabPrefix () {
return `tab:${activeTab.value}|`
}
function expandedLevelKeysThrough (level) {
if (level < 0) return []
const keys = []
collectGroupKeys(keys, activeGroupSourceRows.value, 0, [`tab:${activeTab.value}`], activeGroupLevels.value, level)
return keys
}
function replaceActiveTabExpandedGroups (keys) {
const tabPrefix = activeTabPrefix()
const next = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith(tabPrefix))
)
for (const key of expandableKeys) next[key] = true
for (const key of keys) next[key] = true
expandedGroups.value = next
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.`
})
}
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 = ''
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) {
return row?.group_field || 'product_code'
const field = String(row?.group_field || '')
if (field === 'color_code' || field === 'yaka_kodu' || field === 'color_yaka') return 'color_yaka'
return field || 'product_code'
}
function groupDisplayLabel (row) {
const field = String(row?.group_field || '')
if (field === 'color_code' || field === 'yaka_kodu' || field === 'color_yaka') {
return colorYakaValue(row) || row?.label || row?.group_value || ''
}
return row?.label || row?.group_value || ''
}
function groupIndentPx (row) {
@@ -3065,15 +4243,24 @@ function hasProductVariantImageKey (row) {
}
function canShowProductImage (row) {
return activeTab.value === 'products' && !row?.__group && hasProductVariantImageKey(row)
return isProductKpiTab.value && hasProductVariantImageKey(row)
}
function groupShowsImage () {
return false
function groupShowsImage (row) {
return isProductKpiTab.value &&
row?.__group &&
['product_code', 'color_code', 'yaka_kodu', 'color_yaka'].includes(String(row.group_field || '')) &&
hasProductVariantImageKey(row)
}
const groupDimensionFields = new Set(dimensionColumnNames)
function groupRowClasses (row) {
const level = Math.min(Number(row?.level || 0), 5)
const field = String(row?.group_field || 'unknown').replace(/[^a-z0-9_-]/gi, '-').toLowerCase()
return ['group-row', `group-row-level-${level}`, `group-row-field-${field}`]
}
function groupCellClass (colOrName) {
const name = colOrName?.name || colOrName
if (name === 'image') return 'product-image-cell'
@@ -3087,37 +4274,120 @@ function formatGroupCell (row, colOrName) {
const name = colOrName?.name || colOrName
if (name === 'image') return ''
if (name === 'recommendation') return row.recommendation || ''
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 ''
return formatTableCell(row, colOrName)
}
function withProductMargins (row) {
return {
...row,
avg_price_usd_365d: Number(row?.sales_qty_365d || 0) > 0 ? Number(row?.sales_usd_365d || 0) / Number(row?.sales_qty_365d || 0) : 0,
stock_turnover_90d: stockTurnover(row?.sales_qty_90d, row?.stock_qty),
stock_turnover_180d: stockTurnover(row?.sales_qty_180d, row?.stock_qty),
stock_turnover_365d: stockTurnover(row?.sales_qty_365d, row?.stock_qty),
gross_margin_base_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.base_price_usd),
gross_margin_cost_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.cost_price_usd),
gross_margin_base_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.base_price_usd),
gross_margin_cost_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.cost_price_usd)
const next = normalizeProductCostFields(row)
const out = {
...next,
avg_price_usd_365d: Number(next?.sales_qty_365d || 0) > 0 ? Number(next?.sales_usd_365d || 0) / Number(next?.sales_qty_365d || 0) : 0,
stock_turnover_90d: groupedOrExistingStockTurnover(next, '90d', 90),
stock_turnover_180d: groupedOrExistingStockTurnover(next, '180d', 180),
stock_turnover_365d: groupedOrExistingStockTurnover(next, '365d', 360),
stock_turnover_total: groupedOrExistingStockTurnover(next, 'total', productPerformancePeriodDays(next, 'total'))
}
for (const suffix of ['90d', '180d', '365d', 'total']) {
applyPeriodProfitFields(out, suffix)
}
return out
}
function withGeneralMargins (row) {
const out = normalizeProductCostFields(row)
applyPeriodProfitFields(out, 'total')
return {
...row,
stock_turnover_total: stockTurnover(row?.sales_qty_total, row?.stock_qty),
gross_margin_base_total: marginFromSalesCost(row?.sales_usd_total, row?.sales_qty_total, row?.base_price_usd),
gross_margin_cost_total: marginFromSalesCost(row?.sales_usd_total, row?.sales_qty_total, row?.cost_price_usd)
...out,
stock_turnover_total: groupedOrExistingStockTurnover(out, 'total', productPerformancePeriodDays(out, 'total'))
}
}
function stockTurnover (salesQty, stockQty) {
const stock = Number(stockQty || 0)
if (stock <= 0) return 0
return Number(salesQty || 0) / stock
function normalizeProductCostFields (row) {
const next = { ...row }
normalizeProductCostPairFields(next, 'cost_price_usd', 'base_price_usd')
for (const suffix of ['90d', '180d', '365d', 'total']) {
normalizeProductCostPairFields(next, `cost_price_usd_${suffix}`, `base_price_usd_${suffix}`)
}
return next
}
function normalizeProductCostPairFields (row, costField, baseField) {
const hasCost = Object.prototype.hasOwnProperty.call(row || {}, costField)
const hasBase = Object.prototype.hasOwnProperty.call(row || {}, baseField)
if (!hasCost && !hasBase) return
let costPrice = Number(row?.[costField] || 0)
let basePrice = Number(row?.[baseField] || 0)
if (costPrice > 0 && basePrice > 0 && costPrice > basePrice) {
const tmp = costPrice
costPrice = basePrice
basePrice = tmp
}
if (hasCost || costPrice > 0) row[costField] = costPrice
if (hasBase || basePrice > 0) row[baseField] = basePrice
}
function periodCostPair (row, suffix) {
const costField = `cost_price_usd_${suffix}`
const baseField = `base_price_usd_${suffix}`
let costPrice = Number(row?.[costField] || 0)
let basePrice = Number(row?.[baseField] || 0)
if (costPrice <= 0) costPrice = Number(row?.cost_price_usd || 0)
if (basePrice <= 0) basePrice = Number(row?.base_price_usd || 0)
if (costPrice > 0 && basePrice > 0 && costPrice > basePrice) {
const tmp = costPrice
costPrice = basePrice
basePrice = tmp
}
return { costPrice, basePrice }
}
function applyPeriodProfitFields (row, suffix) {
const qty = Number(row?.[`sales_qty_${suffix}`] || 0)
const salesUSD = Number(row?.[`sales_usd_${suffix}`] || 0)
const { costPrice, basePrice } = periodCostPair(row, suffix)
row[`base_price_usd_${suffix}`] = basePrice
row[`cost_price_usd_${suffix}`] = costPrice
if (qty > 0) {
row[`avg_price_usd_${suffix}`] = salesUSD / qty
row[`unit_profit_base_${suffix}`] = salesUSD / qty - basePrice
row[`unit_profit_cost_${suffix}`] = salesUSD / qty - costPrice
row[`gross_profit_base_usd_${suffix}`] = salesUSD - qty * basePrice
row[`gross_profit_cost_usd_${suffix}`] = salesUSD - qty * costPrice
row[`gross_profit_usd_${suffix}`] = row[`gross_profit_cost_usd_${suffix}`]
} else {
row[`unit_profit_base_${suffix}`] = 0
row[`unit_profit_cost_${suffix}`] = 0
row[`gross_profit_base_usd_${suffix}`] = 0
row[`gross_profit_cost_usd_${suffix}`] = 0
row[`gross_profit_usd_${suffix}`] = 0
}
row[`gross_margin_base_${suffix}`] = marginFromSalesCost(salesUSD, qty, basePrice)
row[`gross_margin_cost_${suffix}`] = marginFromSalesCost(salesUSD, qty, costPrice)
}
function stockTurnover (salesQty, stockQty, periodDays) {
return annualizedStockTurnover(salesQty, stockQty, periodDays)
}
function existingOrStockTurnover (value, salesQty, stockQty, periodDays) {
const current = Number(value)
return value !== undefined && value !== null && Number.isFinite(current) ? current : stockTurnover(salesQty, stockQty, 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) {
@@ -3129,6 +4399,7 @@ function isColumnFilterable (name) {
}
function columnFilterOptions (tableKey, name) {
if (backendGroupedSupportedTab(tableKey) && !backendGroupedFilterFields.has(name)) return []
const options = columnFilterOptionMap.value[tableKey]?.[name] || []
const search = String(columnFilterSearch[filterKey(tableKey, name)] || '').trim().toLocaleLowerCase('tr')
if (!search) return options
@@ -3180,36 +4451,26 @@ function selectAllColumnFilterOptions (tableKey, name) {
}
function runWithFilterBusy (apply) {
filterBusy.value = true
const done = () => {
filterBusy.value = false
}
const execute = () => {
apply()
if (typeof window === 'undefined') {
setTimeout(done, 0)
return
syncExpansionAfterFilterChange()
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(done)
})
}
if (typeof window === 'undefined') {
execute()
return
}
window.requestAnimationFrame(execute)
function syncExpansionAfterFilterChange () {
if (!backendGroupedSupportedTab(activeTab.value)) return
const level = selectedExpandThroughLevel.value
applyActiveExpansionThroughLevel(level >= 0 ? autoExpandThroughLevel.value : -1)
}
function filterRowsForTable (tableKey, sourceRows, sourceColumns) {
return sourceRows.filter(row => {
return sourceColumns.every(col => {
if (!isColumnFilterable(col.name)) return true
const selected = selectedColumnFilters(tableKey, col.name)
if (!selected.length) return true
return selected.includes(columnFilterValue(row, col))
})
})
const activeFilters = sourceColumns
.filter(col => isColumnFilterable(col.name))
.map(col => ({
col,
selected: new Set(selectedColumnFilters(tableKey, col.name))
}))
.filter(item => item.selected.size > 0)
if (!activeFilters.length) return sourceRows
return sourceRows.filter(row => activeFilters.every(item => item.selected.has(columnFilterValue(row, item.col))))
}
function columnFilterValue (row, col) {
@@ -3220,6 +4481,48 @@ function columnFilterLabel (row, col) {
return formattedColumnCellValue(row, col) || columnFilterValue(row, col)
}
function colorYakaValue (row) {
const groupField = String(row?.group_field || '')
const groupValue = String(row?.group_value || row?.label || '').trim()
const color = String(row?.image_color_code || row?.color_code || '').trim()
const colorDescription = String(row?.image_color_description || row?.color_description || '').trim()
const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
const colorLabel = colorDisplayLabel(groupField === 'color_code' ? groupValue || color : color, colorDescription)
if (row?.__group && groupField === 'color_yaka') {
const parts = groupValue.split('/').map(part => part.trim()).filter(Boolean)
const groupColor = parts[0] || ''
const groupYaka = parts.slice(1).join('/')
return [colorDisplayLabel(groupColor, colorDescription), groupYaka].filter(value => value && value !== '-').join('/')
}
if (row?.__group && groupField === 'color_code') return colorLabel
if (row?.__group && groupField === 'yaka_kodu') {
const parentColor = colorLabel && colorLabel !== '-' ? colorLabel : ''
const groupYaka = groupValue && groupValue !== '-' ? groupValue : yaka
return [parentColor, groupYaka].filter(Boolean).join('/')
}
return [colorLabel, yaka].filter(value => value && value !== '-').join('/')
}
function colorDisplayLabel (code, description) {
const colorCode = String(code || '').trim()
const desc = String(description || '').trim()
if (!colorCode) return ''
if (!desc) return colorCode
return `${colorCode}-${desc.toLocaleUpperCase('tr-TR')}`
}
function colorYakaGroupLabel (groupField, groupValue, colorSummary, yakaSummary) {
const field = String(groupField || '')
const value = String(groupValue || '').trim()
if (field === 'color_code') return value
if (field === 'yaka_kodu') {
const color = String(colorSummary || '').trim()
const yaka = value || String(yakaSummary || '').trim()
return [color, yaka].filter(part => part && part !== '-').join('/')
}
return value
}
function productCategoryValue (row) {
return row?.kategori || ''
}
@@ -3229,11 +4532,23 @@ function productFirstGroupValue (row) {
}
function productMainGroupValue (row) {
return row?.urun_ana_grubu || ''
const direct = String(row?.urun_ana_grubu || '').trim()
if (direct) return direct
if (row?.__group && row.group_field === 'urun_ana_grubu') {
const groupValue = String(row.group_value || row.label || '').trim()
if (groupValue) return groupValue
}
return String(row?.urun_alt_grubu || row?.kategori || '').trim()
}
function cleanOptionalGroupValue (value) {
const text = String(value || '').trim()
return text === '-' ? '' : text
}
function cleanProductFirstGroupValue (value) {
const text = String(value || '').trim()
if (text === '-') return ''
const normalized = text
.toLocaleUpperCase('tr-TR')
.replaceAll('İ', 'I')
@@ -3247,8 +4562,10 @@ function cleanProductFirstGroupValue (value) {
function rawProductCellValue (row, name) {
switch (name) {
case 'color_yaka': return colorYakaValue(row)
case 'kategori': return productCategoryValue(row)
case 'urun_ilk_grubu': return productFirstGroupValue(row)
case 'askili_yan': return cleanOptionalGroupValue(row?.askili_yan)
case 'urun_ana_grubu': return productMainGroupValue(row)
case 'market_key': return displayMarketName(row.market_key)
case 'performance_bucket': return bucketLabel(row.performance_bucket)
@@ -3259,8 +4576,10 @@ function rawProductCellValue (row, name) {
function rawColumnCellValue (row, col) {
const name = col?.name || col
switch (name) {
case 'color_yaka': return colorYakaValue(row)
case 'kategori': return productCategoryValue(row)
case 'urun_ilk_grubu': return productFirstGroupValue(row)
case 'askili_yan': return cleanOptionalGroupValue(row?.askili_yan)
case 'urun_ana_grubu': return productMainGroupValue(row)
case 'market_key': return displayMarketName(row.market_key)
case 'performance_bucket': return bucketLabel(row.performance_bucket)
@@ -3318,7 +4637,7 @@ function parseSortNumber (value) {
if (!text) return NaN
const normalized = text
.replace(/\s/g, '')
.replace(/[%$₺€£]/g, '')
.replace(/[%$\u20ba\u20ac\u00a3]/g, '')
.replace(/\.(?=\d{3}(\D|$))/g, '')
.replace(/,/g, '.')
.replace(/[^\d.-]/g, '')
@@ -3334,6 +4653,7 @@ function formatTableCell (row, colOrName) {
const col = typeof colOrName === 'object' ? colOrName : null
const name = col?.name || colOrName
if (name === 'image') return ''
if (name === 'color_yaka') return colorYakaValue(row)
if (['kategori', 'urun_ilk_grubu', 'urun_ana_grubu'].includes(name)) return rawProductCellValue(row, name) || ''
if (name === 'market_key') return displayMarketName(row.market_key)
if (name === 'performance_bucket') return bucketLabel(row.performance_bucket)
@@ -3341,7 +4661,7 @@ function formatTableCell (row, colOrName) {
if (isMoneyField(name)) return formatMoney(row[name], 'USD')
if (isPercentField(name)) return formatPercent(row[name])
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)
return formatProductCell(row, name)
}
@@ -3385,7 +4705,7 @@ function formatProductCell (row, name) {
case 'sales_index_90d':
case 'sales_index_total':
case 'performance_score':
return formatNumber(row[name], name === 'performance_score' ? 1 : 2)
return formatNumber(row[name], 2)
case 'sales_usd_90d':
case 'sales_usd_180d':
case 'sales_usd_365d':
@@ -3405,11 +4725,21 @@ function formatProductCell (row, name) {
case 'gross_profit_usd_90d':
case 'gross_profit_usd_180d':
case 'gross_profit_usd_total':
case 'gross_profit_base_usd_90d':
case 'gross_profit_cost_usd_90d':
case 'gross_profit_base_usd_180d':
case 'gross_profit_cost_usd_180d':
case 'gross_profit_base_usd_365d':
case 'gross_profit_cost_usd_365d':
case 'gross_profit_base_usd_total':
case 'gross_profit_cost_usd_total':
return formatMoney(row[name], 'USD')
case 'gross_margin_base_90d':
case 'gross_margin_cost_90d':
case 'gross_margin_base_180d':
case 'gross_margin_cost_180d':
case 'gross_margin_base_365d':
case 'gross_margin_cost_365d':
case 'gross_margin_base_total':
case 'gross_margin_cost_total':
case 'gross_margin_90d':
@@ -3433,19 +4763,19 @@ function normalizeRow (row) {
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return withPeriodScores(withProductMargins({
return withPeriodScores(withGeneralMargins(withProductMargins({
...row,
row_key: `${product}|${color}|${yaka}|${market}`
}))
})))
}
function withPeriodScores (row) {
const next = { ...row }
next.performance_score_90d = Number(next.performance_score_90d || productSalesPeriodScore(productPeriodMetricSource(next, '90d')))
next.performance_score_180d = Number(next.performance_score_180d || productSalesPeriodScore(productPeriodMetricSource(next, '180d')))
next.performance_score_365d = Number(next.performance_score_365d || productSalesPeriodScore(productPeriodMetricSource(next, '365d')))
next.performance_score_total = Number(next.performance_score_total || productSalesPeriodScore(productPeriodMetricSource(next, 'total')))
if (!Number(next.performance_score || 0)) next.performance_score = next.performance_score_90d
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_90d')) next.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(next, '90d'))
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_180d')) next.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(next, '180d'))
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_365d')) next.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(next, '365d'))
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_total')) next.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(next, 'total'))
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score')) next.performance_score = next.performance_score_90d
return next
}
@@ -3566,47 +4896,47 @@ function productImageKey (row) {
return `${product}|${color}|${yaka}`
}
function productVariantMetricKey (row) {
const product = String(row?.image_product_code || row?.product_code || '').trim()
if (!product) return ''
const color = String(row?.image_color_code || row?.color_code || '').trim()
const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
return `${product}|${color}|${yaka}`
}
function displayMarketName (value) {
const parts = String(value || '').split('|').map(part => part.trim()).filter(Boolean)
return parts.length ? parts[parts.length - 1] : ''
}
function normalizeUploadsPath (storagePath) {
const raw = String(storagePath || '').trim()
if (!raw) return ''
const normalized = raw.replace(/\\/g, '/')
const idx = normalized.toLowerCase().indexOf('/uploads/')
if (idx >= 0) return normalized.slice(idx)
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
return ''
function resolveProductImageUrl (item) {
return resolveProductImageUrls(item)[0] || ''
}
function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return ''
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) return contentURL
if (contentURL.startsWith('/')) return `/api${contentURL}`
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
function resolveProductImageUrls (item) {
if (!item || typeof item !== 'object') return []
const urls = []
const addURL = value => {
const url = String(value || '').trim()
if (url && !urls.includes(url)) urls.push(url)
}
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
if (thumbURL) return thumbURL
addURL(thumbURL)
const fullURL = String(item.full_url || item.fullUrl || '').trim()
if (fullURL) return fullURL
addURL(fullURL)
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
if (uploadsPath) return uploadsPath
const fileName = String(item.file_name || item.FileName || '').trim()
return fileName ? `/uploads/image/${fileName}` : ''
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) addURL(contentURL)
else if (contentURL.startsWith('/')) addURL(`/api${contentURL}`)
return urls
}
async function fetchProductImagesForRow (row) {
if (!hasProductVariantImageKey(row)) return []
const key = productImageKey(row)
if (imageFailedKeys.value.has(key)) return []
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) {
return imageListByKey.value[key]
}
@@ -3633,8 +4963,13 @@ async function fetchProductImagesForRow (row) {
timeout: 60000
})
const list = Array.isArray(resp?.data) ? resp.data : []
const urls = list.map(resolveProductImageUrl).filter(Boolean)
const urls = list.flatMap(resolveProductImageUrls).filter(Boolean)
performanceStore.setImageCache(key, urls)
if (urls.length && imageFailedKeys.value.has(key)) {
const nextFailed = new Set(imageFailedKeys.value)
nextFailed.delete(key)
imageFailedKeys.value = nextFailed
}
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
return urls
@@ -3649,19 +4984,64 @@ async function fetchProductImagesForRow (row) {
function getCachedProductImageUrl (row) {
if (!canShowProductImage(row)) return ''
const key = productImageKey(row)
return imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
if (imageFailedKeys.value.has(key)) return ''
const url = imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
if (!url) queueVisibleProductImage(row)
return url
}
function markProductImageFailed (row) {
function queueVisibleProductImage (row) {
const key = productImageKey(row)
if (!key || imageLoadingKeys.value.has(key) || imageFailedKeys.value.has(key)) return
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) return
imageLoadingKeys.value = new Set([...imageLoadingKeys.value, key])
void fetchProductImagesForRow(row).finally(() => {
const next = new Set(imageLoadingKeys.value)
next.delete(key)
imageLoadingKeys.value = next
})
}
function markProductImageFailed (row, failedUrl = '') {
const key = productImageKey(row)
if (!key) return
const currentUrl = String(failedUrl || imageUrlByKey.value[key] || performanceStore.imageUrl(key) || '').trim()
const list = imageListByKey.value[key] || performanceStore.imageList(key) || []
const nextList = Array.isArray(list) ? list.filter(url => url && !sameImageUrl(url, currentUrl)) : []
if (nextList.length) {
performanceStore.setImageCache(key, nextList)
imageListByKey.value = { ...imageListByKey.value, [key]: nextList }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: nextList[0] || '' }
return
}
imageFailedKeys.value = new Set([...imageFailedKeys.value, key])
performanceStore.setImageCache(key, [])
imageListByKey.value = { ...imageListByKey.value, [key]: [] }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' }
}
function sameImageUrl (left, right) {
const a = String(left || '').trim()
const b = String(right || '').trim()
if (!a || !b) return false
if (a === b) return true
try {
return new URL(a, window.location.origin).href === new URL(b, window.location.origin).href
} catch {
return a.endsWith(b) || b.endsWith(a)
}
}
function markDialogImageFailed (url) {
const failedUrl = String(url || '').trim()
if (!failedUrl) return
imageDialogUrls.value = imageDialogUrls.value.filter(item => item && item !== failedUrl)
if (imageSlide.value >= imageDialogUrls.value.length) imageSlide.value = 0
if (imageDialogRow.value) markProductImageFailed(imageDialogRow.value, failedUrl)
}
async function primeProductImages (sourceRows) {
if (activeTab.value !== 'products') return
if (!isProductKpiTab.value) return
imagePrimeActiveRequests += 1
imagePrimeLoading.value = true
const items = []
@@ -3671,7 +5051,7 @@ async function primeProductImages (sourceRows) {
if (!canShowProductImage(row)) continue
const key = productImageKey(row)
const code = String(row?.image_product_code || row?.product_code || '').trim()
if (!key || !code || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) continue
if (!key || !code || imageFailedKeys.value.has(key) || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) continue
seen.add(key)
items.push({
key,
@@ -3688,8 +5068,9 @@ async function primeProductImages (sourceRows) {
const nextUrls = { ...imageUrlByKey.value }
for (const item of items) {
const urls = batch?.lists?.[item.key] || performanceStore.imageList(item.key) || []
if (!urls.length) imageFailedKeys.value = new Set([...imageFailedKeys.value, item.key])
nextLists[item.key] = urls
nextUrls[item.key] = batch?.urls?.[item.key] || urls[0] || ''
nextUrls[item.key] = imageFailedKeys.value.has(item.key) ? '' : (batch?.urls?.[item.key] || urls[0] || '')
}
imageListByKey.value = nextLists
imageUrlByKey.value = nextUrls
@@ -3745,13 +5126,13 @@ async function loadProductDetailRows (row) {
}
if (!params.product_code) return
const [salesResp, stockResp] = await Promise.all([
api.get('/pricing/product-performance/sales-details', { params: { ...params, limit: 200 }, timeout: 60000 }),
api.get('/pricing/product-performance/sales-details', { params: { ...params, limit: 500 }, timeout: 60000 }),
api.get('/pricing/product-performance/stock-sizes', { params, timeout: 60000 })
])
detailSalesRows.value = (Array.isArray(salesResp?.data) ? salesResp.data : []).map((item, idx) => ({
...item,
detail_key: `${item.sales_date}|${item.ref_number}|${item.customer_code}|${idx}`
}))
})).sort((a, b) => Number(b.sales_usd || 0) - Number(a.sales_usd || 0))
detailStockSizes.value = Array.isArray(stockResp?.data) ? stockResp.data : []
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün detay analizi alınamadı' })
@@ -3762,6 +5143,7 @@ async function loadProductDetailRows (row) {
function backendGroupedSupportedTab (tabKey = activeTab.value) {
return tabKey === 'products' ||
tabKey === 'product_detail' ||
tabKey === 'idle' ||
tabKey === 'order_product_customers' ||
tabKey === 'order_market_details' ||
@@ -3771,16 +5153,22 @@ function backendGroupedSupportedTab (tabKey = activeTab.value) {
function activeExpandedGroupKeys () {
const tabPrefix = `tab:${activeTab.value}|`
const validFields = new Set(activeGroupLevels.value.map(level => level.key))
const maxKeys = maxBackendExpandedKeysForTab(activeTab.value)
const keys = Object.entries(expandedGroups.value)
.filter(([, value]) => value === true)
.map(([key]) => key)
.filter(key => key.startsWith(tabPrefix))
.filter(key => key.split('|').slice(1).every(part => validFields.has(String(part).split(':')[0])))
.sort((a, b) => {
const depthDiff = a.split('|').length - b.split('|').length
if (depthDiff !== 0) return depthDiff
return a.localeCompare(b, 'tr', { numeric: true })
})
const manualKey = lastManualExpandedGroupKey.value
const out = []
if (manualKey && keys.includes(manualKey)) out.push(manualKey)
for (const key of keys) {
if (out.length >= maxBulkExpandKeys) break
if (out.length >= maxKeys) break
if (key !== manualKey) out.push(key)
}
return out
@@ -3803,29 +5191,114 @@ function scheduleLoadBackendGroupedRows () {
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
backendGroupedTimer = window.setTimeout(() => {
void loadBackendGroupedRows()
}, 120)
}, 260)
}
function cleanupTopScrollbarSync () {
if (activeTableMiddleEl) {
activeTableMiddleEl.removeEventListener('scroll', syncTopScrollbarFromTable)
}
if (topScrollbarRef.value) {
topScrollbarRef.value.removeEventListener('scroll', syncTableFromTopScrollbar)
}
if (topScrollbarResizeObserver) {
topScrollbarResizeObserver.disconnect()
topScrollbarResizeObserver = null
}
activeTableMiddleEl = null
}
function updateTopScrollbarSize () {
const outer = topScrollbarRef.value
const inner = topScrollbarInnerRef.value
const tableMiddle = activeTableMiddleEl
if (!outer || !inner || !tableMiddle) return
inner.style.width = `${tableMiddle.scrollWidth}px`
outer.scrollLeft = tableMiddle.scrollLeft
outer.style.display = tableMiddle.scrollWidth > tableMiddle.clientWidth + 2 ? 'block' : 'none'
}
function syncTopScrollbarFromTable () {
if (topScrollbarSyncing || !topScrollbarRef.value || !activeTableMiddleEl) return
topScrollbarSyncing = true
topScrollbarRef.value.scrollLeft = activeTableMiddleEl.scrollLeft
topScrollbarSyncing = false
}
function syncTableFromTopScrollbar () {
if (topScrollbarSyncing || !topScrollbarRef.value || !activeTableMiddleEl) return
topScrollbarSyncing = true
activeTableMiddleEl.scrollLeft = topScrollbarRef.value.scrollLeft
topScrollbarSyncing = false
}
function setupTopScrollbarSync () {
if (typeof document === 'undefined') return
void nextTick(() => {
cleanupTopScrollbarSync()
activeTableMiddleEl = document.querySelector('.product-performance-page .performance-table .q-table__middle')
if (!activeTableMiddleEl || !topScrollbarRef.value) return
activeTableMiddleEl.addEventListener('scroll', syncTopScrollbarFromTable, { passive: true })
topScrollbarRef.value.addEventListener('scroll', syncTableFromTopScrollbar, { passive: true })
if (typeof ResizeObserver !== 'undefined') {
topScrollbarResizeObserver = new ResizeObserver(updateTopScrollbarSize)
topScrollbarResizeObserver.observe(activeTableMiddleEl)
const table = activeTableMiddleEl.querySelector('table')
if (table) topScrollbarResizeObserver.observe(table)
}
updateTopScrollbarSize()
window.setTimeout(updateTopScrollbarSize, 120)
})
}
function switchPerformanceTab (tabName) {
if (!tabName || tabName === activeTab.value || pageBusy.value) return
backendGroupedLoading.value = true
if (!tabName || tabName === activeTab.value || loading.value) return
activeTab.value = tabName
}
async function loadBackendGroupedRows () {
async function loadBackendGroupedRows (options = {}) {
const tabKey = activeTab.value
if (!backendGroupedSupportedTab(tabKey)) return
const filterState = backendGroupedFilterState(tabKey)
if (filterState.unsupported) {
backendGroupedLoading.value = false
return
}
if (tabKey === 'product_detail' && !ensureDetailMainGroupSelection()) {
backendGroupedRows.value = {
...backendGroupedRows.value,
product_detail: []
}
backendGroupedLoading.value = false
return
}
void ensureBackendGroupedFilterOptions(tabKey)
pruneActiveExpandedGroups()
backendGroupedLoading.value = true
const filtersKey = JSON.stringify(filterState.filters)
const expandThroughLevel = selectedExpandThroughLevel.value
const sortState = tableSortState(tabKey)
const requestKey = `${tabKey}|${expandThroughLevel}|${selectedDetailMainGroup.value || ''}|${filtersKey}|${sortState.sortBy}|${sortState.descending ? 1 : 0}`
const params = {
mode: tabKey,
groupLevels: activeGroupLevels.value.map(level => level.key),
expandedKeys: activeExpandedGroupKeys(),
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
expandThroughLevel,
limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000,
filters: filterState.filters,
sortBy: sortState.sortBy,
descending: sortState.descending
}
if (tabKey === 'product_detail') {
params.urunAnaGrubu = selectedDetailMainGroup.value
}
try {
const startedAt = performance.now()
const rows = await performanceStore.fetchGroupedRows(params)
const rows = await performanceStore.fetchGroupedRows(params, { force: options.force === true })
const currentFilterState = backendGroupedFilterState(activeTab.value)
const currentSortState = tableSortState(activeTab.value)
const currentKey = `${activeTab.value}|${selectedExpandThroughLevel.value}|${selectedDetailMainGroup.value || ''}|${JSON.stringify(currentFilterState.filters)}|${currentSortState.sortBy}|${currentSortState.descending ? 1 : 0}`
if (requestKey !== currentKey) return
const normalizedRows = Array.isArray(rows) ? rows.map(row => normalizeGroupedDisplayRow(tabKey, row)) : []
backendGroupedRows.value = {
...backendGroupedRows.value,
@@ -3846,17 +5319,7 @@ async function loadBackendGroupedRows () {
function normalizeGroupedDisplayRow (tabKey, row) {
const nextRow = { ...row }
normalizeGroupedProductHierarchyValues(nextRow)
if (tabKey === 'order_product_customers' || tabKey === 'order_market_details') {
return withOrderPeriodScores(nextRow)
}
const next = withPeriodScores(nextRow)
if (tabKey !== 'products' && tabKey !== 'idle') {
next.customer_score_90d = Number(next.customer_score_90d || customerSalesPeriodScore(periodMetricSource(next, '90d')))
next.customer_score_180d = Number(next.customer_score_180d || customerSalesPeriodScore(periodMetricSource(next, '180d')))
next.customer_score_365d = Number(next.customer_score_365d || customerSalesPeriodScore(periodMetricSource(next, '365d')))
next.customer_score_total = Number(next.customer_score_total || customerSalesPeriodScore(periodMetricSource(next, 'total')))
}
return next
return withPeriodScores(withGeneralMargins(withProductMargins(nextRow)))
}
function normalizeGroupedProductHierarchyValues (row) {
@@ -3902,71 +5365,226 @@ async function timedProductPerformanceGet (label, url, config = {}) {
}
}
function productPerformanceExcelExportTableKey () {
if (isProductKpiTab.value) return activeProductTableKey.value
if (backendGroupedSupportedTab(activeTab.value)) return activeTab.value
return 'products'
}
function buildProductPerformanceExcelExportFilters () {
const tableKey = productPerformanceExcelExportTableKey()
const out = {}
for (const col of filterColumnsForTableKey(tableKey) || []) {
const name = String(col?.name || '').trim()
if (!productPerformanceExcelExportFilterFields.has(name)) continue
const selected = selectedColumnFilters(tableKey, name)
if (selected.length) out[name] = selected
}
if (activeTab.value === 'product_detail') {
const mainGroup = String(selectedDetailMainGroup.value || '').trim()
if (mainGroup && !out.urun_ana_grubu?.includes(mainGroup)) {
out.urun_ana_grubu = [...(out.urun_ana_grubu || []), mainGroup]
}
}
return out
}
function productPerformanceExcelFilename (headers = {}) {
const disposition = String(headers?.['content-disposition'] || headers?.['Content-Disposition'] || '').trim()
const utfMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utfMatch?.[1]) return decodeURIComponent(utfMatch[1].replaceAll('"', '').trim())
const match = disposition.match(/filename="?([^";]+)"?/i)
return match?.[1]?.trim() || `product_performance_color_yaka_${new Date().toISOString().slice(0, 10)}.xlsx`
}
function downloadProductPerformanceExcelBlob (blob, filename) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
async function exportProductPerformanceExcel () {
if (excelExportLoading.value) return
excelExportLoading.value = true
try {
const tableKey = productPerformanceExcelExportTableKey()
const pagination = tablePagination[tableKey] || tablePagination.products || {}
const response = await api.post('/pricing/product-performance/export-excel', {
filters: buildProductPerformanceExcelExportFilters(),
sort_by: pagination.sortBy || '',
descending: pagination.descending !== false
}, {
responseType: 'blob',
timeout: 180000
})
downloadProductPerformanceExcelBlob(response.data, productPerformanceExcelFilename(response.headers || {}))
} catch (err) {
const detail = await extractApiErrorDetail(err)
Notify.create({ type: 'negative', message: detail || 'Excel çıktısı alınamadı' })
} finally {
excelExportLoading.value = false
}
}
async function reload () {
loading.value = true
startLoadingTimer('Rapor verileri hazırlanıyor...')
try {
const salesBreakdownRequests = salesBreakdownTabKeys.map(tabKey => {
return timedProductPerformanceGet(`sales-breakdown:${tabKey}`, '/pricing/product-performance/sales-breakdown', {
params: { limit: 800, mode: salesBreakdownModes[tabKey] },
timeout: 60000
})
})
const [summaryResp, listResp, generalResp, marketResp, countryResp, customerResp, ...salesBreakdownResp] = await Promise.all([
timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 60000 }),
timedProductPerformanceGet('product-kpi', '/pricing/product-performance', {
params: {
page: 1,
limit: productKpiFetchLimit
},
timeout: 60000
}),
timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: 500 }, timeout: 60000 }),
timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: 500 }, timeout: 60000 }),
timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: 500 }, timeout: 60000 }),
timedProductPerformanceGet('customers', '/pricing/product-performance/customers', {
params: { limit: 500, breakdown: customerBreakdown.value },
timeout: 60000
}),
...salesBreakdownRequests
rows.value = []
detailMainGroupOptionRows.value = []
generalRowsLoaded.value = false
generalRows.value = []
orderProductCustomersLoaded.value = false
orderProductCustomerRows.value = []
orderMarketDetailsLoaded.value = false
orderMarketDetailRows.value = []
orderAnalysisLoaded.value = false
orderAnalysisRows.value = []
orderMarketRows.value = []
orderCustomerRows.value = []
marketRowsLoaded.value = false
marketRows.value = []
countryRowsLoaded.value = false
countryRows.value = []
customerRowsLoaded.value = false
customerRows.value = []
for (const key of salesBreakdownTabKeys) salesBreakdownRows[key] = []
loadingStage.value = 'Ana KPI ve özet yükleniyor...'
const [summaryResp] = await Promise.all([
timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 180000 }),
loadDetailMainGroupOptions(true)
])
summary.value = summaryResp?.data || {}
const normalizedGeneralRows = (Array.isArray(generalResp?.data) ? generalResp.data : []).map(normalizeGeneralRow)
const generalByVariant = new Map(normalizedGeneralRows.map(row => [performanceVariantKey(row), generalMetricFields(row)]))
rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(row => {
const generalMetrics = generalByVariant.get(performanceVariantKey(row)) || {}
return normalizeRow({ ...row, ...generalMetrics })
})
generalRows.value = normalizedGeneralRows
marketRows.value = Array.isArray(marketResp?.data) ? marketResp.data : []
countryRows.value = (Array.isArray(countryResp?.data) ? countryResp.data : []).map(row => ({
...row,
country_key: `${row.country || '-'}|${row.customer_segment || '-'}|${row.market_key || '-'}`
}))
customerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(row => ({
...row,
customer_key: `${row.breakdown || '-'}|${row.market_key || '-'}|${row.country || '-'}|${row.customer_segment || '-'}|${row.customer_code || '-'}`
}))
salesBreakdownTabKeys.forEach((tabKey, index) => {
salesBreakdownRows[tabKey] = (Array.isArray(salesBreakdownResp[index]?.data) ? salesBreakdownResp[index].data : []).map(normalizeSalesBreakdownRow)
})
if (activeTab.value === 'order_product_customers') {
await loadOrderProductCustomers(false)
}
if (activeTab.value === 'order_market_details') {
await loadOrderMarketDetails(false)
}
await loadBackendGroupedRows()
loadingStage.value = 'Rapor ekranı hazırlanıyor...'
await nextTick()
ensureDetailMainGroupSelection()
applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
} finally {
loading.value = false
stopLoadingTimer()
}
}
async function loadDetailMainGroupOptions (force = false) {
const optionRows = await performanceStore.fetchGroupedRows({
mode: 'products',
groupLevels: ['urun_ana_grubu'],
expandThroughLevel: -1,
limit: productKpiFetchLimit
}, { force })
detailMainGroupOptionRows.value = (Array.isArray(optionRows) ? optionRows : [])
.filter(row => row?.__group)
.map(row => {
const normalized = normalizeGroupedDisplayRow('products', row)
const value = productMainGroupValue(normalized) || String(normalized.group_value || normalized.label || '').trim()
return {
value,
label: value || '-',
stock_qty: Number(normalized.stock_qty || 0),
sales_qty_90d: Number(normalized.sales_qty_90d || 0)
}
})
.filter(option => option.value)
}
function startLoadingTimer (stage = '') {
loadingStage.value = stage
loadingStartedAt.value = Date.now()
loadingNow.value = Date.now()
if (loadingTimer) window.clearInterval(loadingTimer)
loadingTimer = window.setInterval(() => {
loadingNow.value = Date.now()
}, 1000)
}
function stopLoadingTimer () {
if (loadingTimer) {
window.clearInterval(loadingTimer)
loadingTimer = null
}
loadingStartedAt.value = 0
loadingNow.value = 0
loadingStage.value = ''
}
async function preloadAllReportTabs () {
const tasks = [
loadGeneralRows(false),
loadOrderProductCustomers(false),
loadOrderMarketDetails(false),
loadMarketRows(false),
loadCountryRows(false),
loadCustomerRows(false),
loadAllSalesBreakdownRows(false)
]
const results = await Promise.allSettled(tasks)
const failed = results.filter(item => item.status === 'rejected')
if (failed.length) {
console.warn('[ProductPerformance][preload] partial failure', failed.map(item => item.reason?.message || item.reason))
}
}
async function loadAllSalesBreakdownRows (showLoading = true) {
if (showLoading) loading.value = true
try {
await Promise.all(salesBreakdownTabKeys.map(key => loadSalesBreakdownRows(key, false)))
} finally {
if (showLoading) loading.value = false
}
}
async function loadSalesBreakdownRows (tabKey, showLoading = true) {
if (!salesBreakdownTabKeys.includes(tabKey)) return
if (Array.isArray(salesBreakdownRows[tabKey]) && salesBreakdownRows[tabKey].length) return
if (showLoading) loading.value = true
try {
const mode = salesBreakdownModes[tabKey] || tabKey
const resp = await timedProductPerformanceGet(`sales:${tabKey}`, '/pricing/product-performance/sales-breakdown', {
params: { mode, limit: reportFetchLimit },
timeout: 180000
})
salesBreakdownRows[tabKey] = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeSalesBreakdownRow)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Satış kırılım verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadGeneralRows (showLoading = true) {
if (generalRowsLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: reportFetchLimit }, timeout: 180000 })
const normalizedGeneralRows = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeGeneralRow)
generalRows.value = normalizedGeneralRows
generalRowsLoaded.value = true
const generalByVariant = new Map(normalizedGeneralRows.map(row => [performanceVariantKey(row), generalMetricFields(row)]))
rows.value = rows.value.map(row => {
const generalMetrics = generalByVariant.get(performanceVariantKey(row)) || {}
return Object.keys(generalMetrics).length ? normalizeRow({ ...row, ...generalMetrics }) : row
})
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Genel performans verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadOrderProductCustomers (showLoading = true) {
if (orderProductCustomersLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 })
const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: reportFetchLimit }, timeout: 180000 })
orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
orderProductCustomersLoaded.value = true
} catch (err) {
@@ -3977,9 +5595,10 @@ async function loadOrderProductCustomers (showLoading = true) {
}
async function loadOrderMarketDetails (showLoading = true) {
if (orderMarketDetailsLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: 800 }, timeout: 90000 })
const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: reportFetchLimit }, timeout: 180000 })
orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow)
orderMarketDetailsLoaded.value = true
} catch (err) {
@@ -3989,13 +5608,129 @@ async function loadOrderMarketDetails (showLoading = true) {
}
}
async function loadMarketRows (showLoading = true) {
if (marketRowsLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: reportFetchLimit }, timeout: 180000 })
marketRows.value = aggregateMarketRowsByDisplayName(Array.isArray(resp?.data) ? resp.data : [])
marketRowsLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Piyasa performans verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
function aggregateMarketRowsByDisplayName (sourceRows) {
const grouped = new Map()
for (const row of sourceRows || []) {
const display = displayMarketName(row?.market_key) || String(row?.market_key || '').trim() || '-'
const current = grouped.get(display) || {
...row,
row_key: `market|${display}`,
market_key: display,
product_count: 0,
star_count: 0,
stock_risk_count: 0,
stock_qty: 0,
stock_cost_value_usd: 0,
risk_stock_cost_value_usd: 0,
sales_qty_90d: 0,
sales_usd_90d: 0,
gross_profit_usd_90d: 0,
_margin_weight: 0,
_margin_total: 0,
_stock_days_weight: 0,
_stock_days_total: 0
}
current.product_count += Number(row?.product_count || 0)
current.star_count += Number(row?.star_count || 0)
current.stock_risk_count += Number(row?.stock_risk_count || 0)
current.stock_qty += Number(row?.stock_qty || 0)
current.stock_cost_value_usd += Number(row?.stock_cost_value_usd || 0)
current.risk_stock_cost_value_usd += Number(row?.risk_stock_cost_value_usd || 0)
current.sales_qty_90d += Number(row?.sales_qty_90d || 0)
current.sales_usd_90d += Number(row?.sales_usd_90d || 0)
current.gross_profit_usd_90d += Number(row?.gross_profit_usd_90d || 0)
const marginWeight = Math.abs(Number(row?.sales_usd_90d || 0))
current._margin_weight += marginWeight
current._margin_total += Number(row?.avg_gross_margin_90d || 0) * marginWeight
const stockDaysWeight = Math.abs(Number(row?.stock_qty || 0))
current._stock_days_weight += stockDaysWeight
current._stock_days_total += Number(row?.avg_stock_days_90d || 0) * stockDaysWeight
grouped.set(display, current)
}
return Array.from(grouped.values())
.map(row => {
row.avg_gross_margin_90d = row._margin_weight > 0 ? row._margin_total / row._margin_weight : 0
row.avg_stock_days_90d = row._stock_days_weight > 0 ? row._stock_days_total / row._stock_days_weight : 0
delete row._margin_weight
delete row._margin_total
delete row._stock_days_weight
delete row._stock_days_total
return row
})
.sort((a, b) => Number(b.risk_stock_cost_value_usd || 0) - Number(a.risk_stock_cost_value_usd || 0) ||
Number(b.stock_cost_value_usd || 0) - Number(a.stock_cost_value_usd || 0) ||
Number(b.sales_usd_90d || 0) - Number(a.sales_usd_90d || 0))
}
async function loadCountryRows (showLoading = true) {
if (countryRowsLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: reportFetchLimit }, timeout: 180000 })
countryRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(row => ({
...row,
country_key: `${row.country || '-'}|${row.customer_segment || '-'}|${row.market_key || '-'}`
}))
countryRowsLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ülke performans verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadCustomerRows (showLoading = true) {
if (customerRowsLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('customers', '/pricing/product-performance/customers', {
params: { limit: reportFetchLimit, breakdown: customerBreakdown.value },
timeout: 180000
})
customerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(row => ({
...row,
customer_key: `${row.breakdown || '-'}|${row.market_key || '-'}|${row.country || '-'}|${row.customer_segment || '-'}|${row.customer_code || '-'}`
}))
customerRowsLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Müşteri performans verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function ensureActiveTabData (showLoading = true) {
const tab = activeTab.value
if (tab === 'general') return loadGeneralRows(showLoading)
if (tab === 'markets') return loadMarketRows(showLoading)
if (tab === 'countries') return loadCountryRows(showLoading)
if (tab === 'customers') return loadCustomerRows(showLoading)
if (backendGroupedSupportedTab(tab)) {
scheduleLoadBackendGroupedRows()
}
}
async function loadOrderAnalysis (showLoading = true) {
if (showLoading) loading.value = true
try {
const [productResp, marketResp, customerResp] = await Promise.all([
timedProductPerformanceGet('orders:products', '/pricing/product-performance/orders', { params: { limit: 500 }, timeout: 90000 }),
timedProductPerformanceGet('orders:groups:market', '/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'market' }, timeout: 90000 }),
timedProductPerformanceGet('orders:groups:customer', '/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'customer' }, timeout: 90000 })
timedProductPerformanceGet('orders:products', '/pricing/product-performance/orders', { params: { limit: reportFetchLimit }, timeout: 90000 }),
timedProductPerformanceGet('orders:groups:market', '/pricing/product-performance/orders/groups', { params: { limit: reportFetchLimit, breakdown: 'market' }, timeout: 90000 }),
timedProductPerformanceGet('orders:groups:customer', '/pricing/product-performance/orders/groups', { params: { limit: reportFetchLimit, breakdown: 'customer' }, timeout: 90000 })
])
orderAnalysisRows.value = (Array.isArray(productResp?.data) ? productResp.data : []).map(normalizeOrderAnalysisRow)
orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow)
@@ -4024,29 +5759,53 @@ function bucketColor (value) {
}
}
watch(activeTab, tab => {
if (tab === 'order_product_customers' && !orderProductCustomersLoaded.value) {
void loadOrderProductCustomers(true)
}
if (tab === 'order_market_details' && !orderMarketDetailsLoaded.value) {
void loadOrderMarketDetails(true)
}
watch(activeTab, () => {
void ensureActiveTabData(false).then(() => {
if (backendGroupedSupportedTab(activeTab.value)) applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
})
setupTopScrollbarSync()
})
watch(activeBackendGroupedSortSignature, () => {
if (!backendGroupedSupportedTab(activeTab.value) || loading.value) return
scheduleLoadBackendGroupedRows()
})
watch(activeSelectedExpandLevelKeys, () => {
scheduleLoadBackendGroupedRows()
watch(activeDisplayedTableRows, () => {
setupTopScrollbarSync()
}, { flush: 'post' })
watch(detailMainGroupOptions, () => {
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 => {
if (activeTab.value !== 'products') return
watch(activeDisplayedTableRows, tableRows => {
if (!isProductKpiTab.value) return
const imageRows = tableRows
.filter(row => !row.__group || groupShowsImage(row))
.slice(0, 240)
void primeProductImages(imageRows)
}, { immediate: true })
onMounted(reload)
onMounted(() => {
void reload()
setupTopScrollbarSync()
})
onBeforeUnmount(() => {
cleanupTopScrollbarSync()
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
stopLoadingTimer()
})
</script>
<style scoped>
@@ -4064,7 +5823,7 @@ onMounted(reload)
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(246, 247, 249, 0.82);
background: #f6f7f9;
backdrop-filter: blur(2px);
pointer-events: all;
}
@@ -4075,6 +5834,12 @@ onMounted(reload)
font-weight: 700;
}
.page-busy-time {
color: #5f6b7a;
font-size: 13px;
font-weight: 600;
}
.performance-top-bar {
min-height: 34px;
}
@@ -4173,6 +5938,49 @@ onMounted(reload)
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 {
min-height: 38px;
}
@@ -4203,8 +6011,31 @@ onMounted(reload)
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) {
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) {
@@ -4289,125 +6120,279 @@ onMounted(reload)
.performance-table :deep(.q-table tbody td .q-field__control),
.performance-table :deep(.q-table tbody td .q-field__native),
.performance-table :deep(.q-table tbody td .q-field__input) {
min-height: 26px;
font-size: 11px;
min-height: 24px;
font-size: 10px;
line-height: 1.2;
}
.performance-table :deep(.q-table th.color-yaka-cell),
.performance-table :deep(.q-table td.color-yaka-cell) {
width: 170px;
min-width: 170px;
max-width: 190px;
font-size: 10.5px;
}
.performance-table :deep(.q-table th:first-child),
.performance-table :deep(.q-table td:first-child) {
border-left: 1px solid #d5dce5;
}
.performance-table :deep(th) {
.performance-table :deep(.q-table thead),
.performance-table :deep(.q-table thead tr),
.performance-table :deep(.q-table th) {
position: sticky;
top: 0;
z-index: 1;
z-index: 20;
background: #f8fbff;
}
.performance-table :deep(th) {
border-top: 1px solid #d5dce5;
}
.product-breakdown-table :deep(.q-table) {
min-width: 3000px;
min-width: 3170px;
table-layout: fixed;
}
.product-breakdown-table :deep(.q-table th:nth-child(-n+10)),
.product-breakdown-table :deep(.q-table td:nth-child(-n+10)) {
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(-n+10)) {
position: sticky;
z-index: 2;
}
.product-breakdown-table :deep(.q-table th:nth-child(-n+10)) {
z-index: 4;
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)) {
z-index: 30;
background: #f8fbff;
}
.product-breakdown-table :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) {
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) {
background: #fff;
}
.product-breakdown-table :deep(.q-table th:nth-child(1)),
.product-breakdown-table :deep(.q-table td:nth-child(1)) {
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(1)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(1)) {
left: 0;
width: 72px;
min-width: 72px;
max-width: 72px;
width: 190px;
min-width: 190px;
max-width: 190px;
}
.product-breakdown-table :deep(.q-table th:nth-child(2)),
.product-breakdown-table :deep(.q-table td:nth-child(2)) {
left: 72px;
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(2)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(2)) {
left: 190px;
width: 140px;
min-width: 140px;
max-width: 140px;
}
.product-breakdown-table :deep(.q-table th:nth-child(3)),
.product-breakdown-table :deep(.q-table td:nth-child(3)) {
left: 212px;
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(3)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(3)) {
left: 330px;
width: 110px;
min-width: 110px;
max-width: 110px;
}
.product-breakdown-table :deep(.q-table th:nth-child(4)),
.product-breakdown-table :deep(.q-table td:nth-child(4)) {
left: 322px;
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(4)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(4)) {
left: 440px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table :deep(.q-table th:nth-child(5)),
.product-breakdown-table :deep(.q-table td:nth-child(5)) {
left: 472px;
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(5)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(5)) {
left: 590px;
width: 170px;
min-width: 170px;
max-width: 170px;
}
.product-breakdown-table :deep(.q-table th:nth-child(6)),
.product-breakdown-table :deep(.q-table td:nth-child(6)) {
left: 642px;
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(6)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(6)) {
left: 760px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table :deep(.q-table th:nth-child(7)),
.product-breakdown-table :deep(.q-table td:nth-child(7)) {
left: 792px;
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(7)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(7)) {
left: 910px;
width: 130px;
min-width: 130px;
max-width: 130px;
}
.product-breakdown-table :deep(.q-table th:nth-child(8)),
.product-breakdown-table :deep(.q-table td:nth-child(8)) {
left: 922px;
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(8)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(8)) {
left: 1040px;
width: 90px;
min-width: 90px;
max-width: 90px;
}
.product-breakdown-table :deep(.q-table th:nth-child(9)),
.product-breakdown-table :deep(.q-table td:nth-child(9)) {
left: 1012px;
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(9)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(9)) {
left: 1130px;
width: 80px;
min-width: 80px;
max-width: 80px;
}
.product-breakdown-table :deep(.q-table th:nth-child(10)),
.product-breakdown-table :deep(.q-table td:nth-child(10)) {
left: 1092px;
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(10)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(10)) {
left: 1210px;
width: 130px;
min-width: 130px;
max-width: 130px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.product-breakdown-table.compact-detail-columns :deep(.q-table) {
min-width: 2450px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(-n+4)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(-n+4)) {
position: sticky;
z-index: 2;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(-n+4)) {
z-index: 30;
background: #f8fbff;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+4)) {
background: #fff;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(1)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(1)) {
left: 0;
width: 86px;
min-width: 86px;
max-width: 86px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(2)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(2)) {
left: 86px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(3)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(3)) {
left: 236px;
width: 112px;
min-width: 112px;
max-width: 112px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(4)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(4)) {
left: 348px;
width: 132px;
min-width: 132px;
max-width: 132px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.product-breakdown-table.product-detail-table :deep(.q-table) {
min-width: 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) {
min-width: 1700px;
table-layout: fixed;
@@ -4424,16 +6409,25 @@ onMounted(reload)
.sticky-dim-table :deep(.q-table td.text-right),
.product-breakdown-table :deep(.q-table th.text-right),
.product-breakdown-table :deep(.q-table td.text-right) {
min-width: 98px;
width: 98px;
min-width: 124px;
width: 124px;
max-width: none;
white-space: nowrap;
}
.product-breakdown-table :deep(.q-table th:nth-child(n+11):not(.text-right)),
.product-breakdown-table :deep(.q-table td:nth-child(n+11):not(.text-right)),
.product-breakdown-table :deep(.q-table td.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 td:nth-child(n+4):not(.text-right)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(n+5):not(.text-right)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(n+5):not(.text-right)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(n+6):not(.text-right)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(n+6):not(.text-right)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(n+7):not(.text-right)),
@@ -4513,6 +6507,8 @@ onMounted(reload)
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(-n+3)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(-n+4)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(-n+4)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(-n+5)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)),
@@ -4532,6 +6528,7 @@ onMounted(reload)
}
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(-n+4)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)),
.sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)),
@@ -4539,7 +6536,7 @@ onMounted(reload)
.sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(-n+9)),
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)),
.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(-n+11)) {
z-index: 4;
z-index: 30;
background: #f8fbff;
}
@@ -4549,6 +6546,8 @@ onMounted(reload)
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(3)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(3)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(4)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(4)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(5)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(5)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(6)),
@@ -4566,6 +6565,63 @@ onMounted(reload)
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 {
vertical-align: middle;
}
@@ -4643,39 +6699,61 @@ onMounted(reload)
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-0 td) {
background: #2f5f8f;
background: #123b63;
color: #fff;
border-color: #244b72;
border-color: #0b2845;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-1 td) {
background: #4f7fad;
background: #1f6f54;
color: #fff;
border-color: #426d96;
border-color: #18563f;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-2 td) {
background: #d8e6f4;
background: #ffe2a8;
color: #1f2937;
border-color: #b8cce0;
border-color: #d7a84d;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-3 td) {
background: #e6eef7;
background: #d8f0dc;
color: #1f2937;
border-color: #c4d2e2;
border-color: #9bc7a3;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-4 td) {
background: #eef4fa;
background: #e7ecff;
color: #1f2937;
border-color: #cbd8e6;
border-color: #b8c2ef;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-5 td) {
background: #f4f8fc;
background: #f2f3f5;
color: #1f2937;
border-color: #d5dce5;
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),
@@ -4713,32 +6791,38 @@ onMounted(reload)
}
.product-image-cell {
width: 64px;
min-width: 64px;
width: 190px;
min-width: 190px;
max-width: 190px;
padding: 10px !important;
text-align: center;
vertical-align: middle;
}
.product-thumb-button {
width: 54px;
height: 72px;
width: 162px;
height: 216px;
border: 1px solid #d9dde3;
border-radius: 6px;
border-radius: 8px;
background: #fff;
padding: 4px;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.product-thumb,
.product-thumb-placeholder {
width: 46px;
height: 64px;
width: 100%;
height: 100%;
}
.product-thumb {
display: block;
object-fit: contain;
object-position: center;
}
.product-thumb-placeholder {
@@ -4886,6 +6970,103 @@ onMounted(reload)
font-size: 14px;
}
.product-performance-dialog {
background: #f6f7f9;
}
.product-performance-dialog :deep(.q-table__container),
.product-image-dialog :deep(.q-table__container) {
width: 100%;
min-width: 0;
}
.product-performance-dialog :deep(.q-table__middle),
.product-image-dialog :deep(.q-table__middle) {
overflow-x: hidden;
}
.dialog-detail-table,
.dialog-stock-table {
width: 100%;
min-width: 0;
font-size: 11px;
}
.dialog-detail-table :deep(.q-table),
.dialog-stock-table :deep(.q-table) {
width: 100%;
table-layout: fixed;
}
.dialog-detail-table :deep(.q-table th),
.dialog-detail-table :deep(.q-table td),
.dialog-stock-table :deep(.q-table th),
.dialog-stock-table :deep(.q-table td) {
padding: 4px 5px;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.18;
vertical-align: top;
}
.dialog-detail-table :deep(.q-table th) {
font-size: 10px;
}
.dialog-detail-table :deep(.q-table th:nth-child(1)),
.dialog-detail-table :deep(.q-table td:nth-child(1)) {
width: 9%;
}
.dialog-detail-table :deep(.q-table th:nth-child(2)),
.dialog-detail-table :deep(.q-table td:nth-child(2)) {
width: 12%;
}
.dialog-detail-table :deep(.q-table th:nth-child(3)),
.dialog-detail-table :deep(.q-table td:nth-child(3)) {
width: 11%;
}
.dialog-detail-table :deep(.q-table th:nth-child(4)),
.dialog-detail-table :deep(.q-table td:nth-child(4)) {
width: 9%;
}
.dialog-detail-table :deep(.q-table th:nth-child(5)),
.dialog-detail-table :deep(.q-table td:nth-child(5)) {
width: 12%;
}
.dialog-detail-table :deep(.q-table th:nth-child(6)),
.dialog-detail-table :deep(.q-table td:nth-child(6)) {
width: 23%;
}
.dialog-detail-table :deep(.q-table th:nth-child(7)),
.dialog-detail-table :deep(.q-table td:nth-child(7)),
.dialog-detail-table :deep(.q-table th:nth-child(8)),
.dialog-detail-table :deep(.q-table td:nth-child(8)),
.dialog-detail-table :deep(.q-table th:nth-child(9)),
.dialog-detail-table :deep(.q-table td:nth-child(9)) {
width: 8%;
white-space: nowrap;
overflow-wrap: normal;
word-break: normal;
}
.dialog-stock-table :deep(.q-table th:nth-child(1)),
.dialog-stock-table :deep(.q-table td:nth-child(1)) {
width: 58%;
}
.dialog-stock-table :deep(.q-table th:nth-child(2)),
.dialog-stock-table :deep(.q-table td:nth-child(2)) {
width: 42%;
white-space: nowrap;
}
@media (max-width: 1260px) {
.product-image-dialog-body {
overflow: auto;
+11 -32
View File
@@ -423,7 +423,6 @@ const productImageListByCode = ref({})
const productImageListLoading = ref({})
const productImageFallbackByKey = ref({})
const productImageContentLoading = ref({})
const productImageBlobUrls = ref([])
const productImageListBlockedUntil = ref(0)
const productCardDialog = ref(false)
const productCardData = ref({})
@@ -637,14 +636,9 @@ function resolveProductImageUrl(item) {
}
let contentUrl = ''
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) {
contentUrl = `/api/product-images/${imageId}/content`
} else {
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) contentUrl = contentURL
else if (contentURL.startsWith('/')) contentUrl = `/api${contentURL}`
}
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
let publicUrl = ''
@@ -708,23 +702,12 @@ function clearGalleryQueryIndex() {
async function resolveProductImageUrlForCarousel(item) {
const resolved = resolveProductImageUrl(item)
const contentUrl = String(resolved.contentUrl || '').trim()
if (contentUrl) {
try {
const blobRes = await api.get(contentUrl, { baseURL: '', responseType: 'blob' })
const blob = blobRes?.data
if (blob instanceof Blob) {
const objectUrl = URL.createObjectURL(blob)
productImageBlobUrls.value.push(objectUrl)
return objectUrl
}
} catch {
// fall through to public url
}
}
const fullUrl = String(resolved.fullUrl || '').trim()
if (fullUrl) return fullUrl
const publicUrl = String(resolved.publicUrl || '').trim()
return String(publicUrl || fullUrl || contentUrl || '').trim()
if (contentUrl) return contentUrl
const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim()
if (directUrl) return directUrl
return ''
}
function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
@@ -774,9 +757,9 @@ async function flushProductImageBatch() {
const list = Array.isArray(item.images) ? item.images : []
const first = list[0] || null
const resolved = resolveProductImageUrl(first)
const url = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
const url = resolved.contentUrl || resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
productImageCache.value[key] = String(url || '').trim()
productImageFallbackByKey.value[key] = resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
productImageFallbackByKey.value[key] = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
if (queued) productImageListByCode.value[queued.listKey] = list
delete productImageLoading.value[key]
}
@@ -865,11 +848,11 @@ async function ensureProductImage(code, color, secondColor = '', dim1Id = '', di
productImageCache.value[key] = String(
preferredCardUrl ||
primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || primaryResolved.contentUrl ||
secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || secondaryResolved.contentUrl ||
primaryResolved.contentUrl || primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl ||
secondaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl ||
''
).trim()
productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.contentUrl || ''
productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || ''
} catch (err) {
console.warn('[ProductStockByAttributes] product image fetch failed', { code, color, err })
productImageCache.value[key] = ''
@@ -1581,10 +1564,6 @@ onUnmounted(() => {
clearTimeout(filterOptionsDebounceTimer)
filterOptionsDebounceTimer = null
}
for (const url of productImageBlobUrls.value) {
try { URL.revokeObjectURL(url) } catch {}
}
productImageBlobUrls.value = []
})
</script>
+11 -32
View File
@@ -390,7 +390,6 @@ const productImageListByCode = ref({})
const productImageListLoading = ref({})
const productImageFallbackByKey = ref({})
const productImageContentLoading = ref({})
const productImageBlobUrls = ref([])
const productImageListBlockedUntil = ref(0)
const productCardDialog = ref(false)
const productCardData = ref({})
@@ -618,14 +617,9 @@ function resolveProductImageUrl(item) {
}
let contentUrl = ''
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) {
contentUrl = `/api/product-images/${imageId}/content`
} else {
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) contentUrl = contentURL
else if (contentURL.startsWith('/')) contentUrl = `/api${contentURL}`
}
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
let publicUrl = ''
@@ -687,23 +681,12 @@ function clearGalleryQueryIndex() {
async function resolveProductImageUrlForCarousel(item) {
const resolved = resolveProductImageUrl(item)
const contentUrl = String(resolved.contentUrl || '').trim()
if (contentUrl) {
try {
const blobRes = await api.get(contentUrl, { baseURL: '', responseType: 'blob' })
const blob = blobRes?.data
if (blob instanceof Blob) {
const objectUrl = URL.createObjectURL(blob)
productImageBlobUrls.value.push(objectUrl)
return objectUrl
}
} catch {
// fall through to public url
}
}
const fullUrl = String(resolved.fullUrl || '').trim()
if (fullUrl) return fullUrl
const publicUrl = String(resolved.publicUrl || '').trim()
return String(publicUrl || fullUrl || contentUrl || '').trim()
if (contentUrl) return contentUrl
const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim()
if (directUrl) return directUrl
return ''
}
function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
@@ -753,9 +736,9 @@ async function flushProductImageBatch() {
const list = Array.isArray(item.images) ? item.images : []
const first = list[0] || null
const resolved = resolveProductImageUrl(first)
const url = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
const url = resolved.contentUrl || resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
productImageCache.value[key] = String(url || '').trim()
productImageFallbackByKey.value[key] = resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
productImageFallbackByKey.value[key] = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
if (queued) productImageListByCode.value[queued.listKey] = list
delete productImageLoading.value[key]
}
@@ -841,11 +824,11 @@ async function ensureProductImage(code, color, secondColor = '', dim1Id = '', di
const secondaryResolved = resolveProductImageUrl(secondaryItem)
productImageCache.value[key] = String(
preferredCardUrl ||
primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || primaryResolved.contentUrl ||
secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || secondaryResolved.contentUrl ||
primaryResolved.contentUrl || primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl ||
secondaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl ||
''
).trim()
productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.contentUrl || ''
productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || ''
} catch (err) {
console.warn('[ProductStockQuery] product image fetch failed', { code, color, err })
productImageCache.value[key] = ''
@@ -1379,10 +1362,6 @@ function resetForm() {
onUnmounted(() => {
window.removeEventListener('mousemove', onFullscreenMouseMove)
window.removeEventListener('mouseup', onFullscreenMouseUp)
for (const url of productImageBlobUrls.value) {
try { URL.revokeObjectURL(url) } catch {}
}
productImageBlobUrls.value = []
})
onMounted(async () => {
@@ -40,6 +40,14 @@
:loading="loading"
@click="fetchRows"
/>
<q-btn
label="Excel'e Aktar"
icon="download"
color="primary"
outline
:disable="loading || rows.length === 0"
@click="exportVisibleRows"
/>
</div>
</div>
</div>
@@ -141,7 +149,7 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { usePermission } from 'src/composables/usePermission'
import { get, extractApiErrorDetail } from 'src/services/api'
@@ -168,6 +176,10 @@ const dateColumns = new Set(['Tarihi', 'dteKayitTarihi', 'dteGuncellemeTarihi',
const columns = [
{ name: 'open', label: '', field: 'open', align: 'center', sortable: false, style: 'width:3%', headerStyle: 'width:3%' },
{ name: 'UretimSekli', label: 'Uretim Sekli', field: 'UretimSekli', align: 'left', sortable: true, style: 'width:9%', headerStyle: 'width:9%' },
{ name: 'nUrtSiparisNo', label: 'Uretim Siparis No', field: 'nUrtSiparisNo', align: 'left', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'ceketDepoGiris', label: 'C-Ceket Depo Giris', field: 'ceketDepoGiris', align: 'right', sortable: true, format: val => formatMoney(val), style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'pantolonDepoGiris', label: 'P-Pantolon Depo Giris', field: 'pantolonDepoGiris', align: 'right', sortable: true, format: val => formatMoney(val), style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'yelekDepoGiris', label: 'Y-Yelek Depo Giris', field: 'yelekDepoGiris', align: 'right', sortable: true, format: val => formatMoney(val), style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'nOnMLNo', label: 'nOnMLNo', field: 'nOnMLNo', align: 'left', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'UrunKodu', label: 'UrunKodu', field: 'UrunKodu', align: 'left', sortable: true, style: 'width:7%', headerStyle: 'width:7%' },
{ name: 'UrunAdi', label: 'UrunAdi', field: 'UrunAdi', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
@@ -186,6 +198,30 @@ const columns = [
]
const columnFilters = reactive({})
const LIST_STATE_STORAGE_KEY = 'bssapp:production-product-costing:has-cost-list-state:v1'
function persistListState () {
try {
sessionStorage.setItem(LIST_STATE_STORAGE_KEY, JSON.stringify({
filters: { search: String(filters.search || '') },
columnFilters: JSON.parse(JSON.stringify(columnFilters)),
pagination: { ...tablePagination.value }
}))
} catch {}
}
function restoreListState () {
try {
const raw = sessionStorage.getItem(LIST_STATE_STORAGE_KEY)
if (!raw) return
const saved = JSON.parse(raw)
filters.search = String(saved?.filters?.search || '')
Object.assign(columnFilters, saved?.columnFilters && typeof saved.columnFilters === 'object' ? saved.columnFilters : {})
if (saved?.pagination && typeof saved.pagination === 'object') {
tablePagination.value = { ...tablePagination.value, ...saved.pagination }
}
} catch {}
}
function getColumnFilter (name) {
if (!columnFilters[name]) {
@@ -293,9 +329,11 @@ function clearAllColumnFilters () {
}
let searchTimer = null
let filterWatchEnabled = false
watch(
() => filters.search,
() => {
if (!filterWatchEnabled) return
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
fetchRows()
@@ -348,19 +386,66 @@ function clearFilters () {
fetchRows()
}
function escapeExcelCsvCell (value) {
let text = String(value ?? '')
// Prevent spreadsheet applications from evaluating data as a formula.
if (/^[=+\-@]/.test(text)) text = `'${text}`
return `"${text.replace(/"/g, '""')}"`
}
const excelNumericColumns = new Set(['ceketDepoGiris', 'pantolonDepoGiris', 'yelekDepoGiris', 'lTutarTL', 'lTutarUSD', 'lTutarEURO'])
function formatExcelCsvCell (col, rawValue, displayValue) {
if (excelNumericColumns.has(col?.name)) {
const numericValue = Number(rawValue)
if (Number.isFinite(numericValue)) return String(numericValue).replace('.', ',')
}
return escapeExcelCsvCell(displayValue)
}
function exportVisibleRows () {
const visibleRows = Array.isArray(rows.value) ? rows.value : []
if (visibleRows.length === 0) return
const exportColumns = columns.filter(col => col.name !== 'open')
const csvLines = [
exportColumns.map(col => escapeExcelCsvCell(col.label)).join(';'),
...visibleRows.map(row => exportColumns.map(col => {
const rawValue = typeof col.field === 'function' ? col.field(row) : row?.[col.field]
const displayValue = typeof col.format === 'function' ? col.format(rawValue, row) : rawValue
return formatExcelCsvCell(col, rawValue, displayValue)
}).join(';'))
]
const blob = new Blob([`\uFEFF${csvLines.join('\r\n')}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `mevcut_maliyetli_urunler_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
function openRow (row) {
const urunKodu = String(row?.UrunKodu || '').trim()
if (!urunKodu) return
persistListState()
router.push({
name: 'production-product-costing-has-cost-history',
query: { urun_kodu: urunKodu }
})
}
onMounted(() => {
onMounted(async () => {
if (!canReadOrder.value) return
fetchRows()
restoreListState()
await nextTick()
filterWatchEnabled = true
await fetchRows()
})
</script>
@@ -457,7 +457,7 @@
<div v-else class="column q-gutter-md pcd-content-body">
<div
v-for="(grp, gi) in detailGroups"
v-for="(grp, gi) in detailGroupsWithTotals"
:key="groupKey(grp, gi)"
class="pcd-group-card"
>
@@ -469,7 +469,11 @@
</span>
</div>
<div class="sub-right pcd-sub-right-clickable" @click="toggleGroup(grp, gi)">
Grup Toplami TRY: {{ formatBarMoney(resolveGroupTRYTutar(grp)) }} | USD: {{ formatBarMoney(resolveGroupUSDTutar(grp)) }}
<span class="pcd-group-total-text" data-no-runtime-i18n="true">
Grup Toplamı TRY: {{ formatBarMoney(grp.tryTotal) }}
<span class="pcd-group-total-divider">|</span>
USD: {{ formatBarMoney(grp.usdTotal) }}
</span>
<q-icon
:name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'"
size="18px"
@@ -1557,8 +1561,14 @@ function ensureBeforeUnloadGuard (enabled) {
return ''
}
}
function shouldIncludeRowInCostingTotal (row) {
// The explicit "Maliyete Dahil" selection is authoritative for the top
// header and the amount persisted to spUrtOnMLMas, including CM rows.
return normalizeBooleanFlag(row?.maliyeteDahil ?? row?.maliyete_dahil ?? row?.Maliyete_dahil)
}
const toolbarSummary = computed(() => flatDetailRows.value.reduce((acc, row) => {
if (!row?.maliyeteDahil) return acc
if (!shouldIncludeRowInCostingTotal(row)) return acc
acc.tryTotal += resolveRowTRYTutar(row)
acc.usdTotal += resolveRowUSDTutar(row)
acc.eurTotal += resolveRowEURTutar(row)
@@ -2694,7 +2704,9 @@ function normalizeDetailRows (items, groupName = '') {
// Use fiyat_girilen which is the unit price in original currency.
inputPrice: x?.inputPrice ?? normalizeInputPrice(x?.fiyat_girilen),
inputPricePrBr: originalCurrency,
maliyeteDahil: x?.maliyeteDahil ?? normalizeBooleanFlag(x?.maliyete_dahil ?? x?.Maliyete_dahil),
maliyeteDahil: normalizeBooleanFlag(x?.maliyeteDahil ?? x?.maliyete_dahil ?? x?.Maliyete_dahil),
__lastValidTRYAmount: parseMoneyInput(x?.lTutar ?? x?.lTutarTL),
__lastValidUSDAmount: parseMoneyInput(x?.usdTutar ?? x?.lTutarUSD),
cmPriceTypeId: normalizeCMPriceTypeId(x?.cmPriceTypeId ?? x?.cm_price_type_id, groupName || x?.sAciklama3),
draftChanged: Boolean(x?.draftChanged),
priceUpdateState: String(x?.priceUpdateState || '').trim()
@@ -2796,6 +2808,13 @@ function recalculateDetailRow (row, options = {}) {
row.lTutarGBP = row.gbpTutar
row.lTutarTL = row.lTutar
if (row.lTutar !== 0 || options.markChanged) {
row.__lastValidTRYAmount = row.lTutar
}
if (row.usdTutar !== 0 || options.markChanged) {
row.__lastValidUSDAmount = row.usdTutar
}
if (!options.preserveInputs) {
row.miktarInput = normalizeQuantityInput(quantity)
row.inputPrice = normalizeInputPrice(inputPrice)
@@ -2817,58 +2836,124 @@ function recalculateDetailRow (row, options = {}) {
}
function recalculateAllDetailRows () {
detailGroups.value = detailGroups.value.map(grp => ({
...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => recalculateDetailRow({ ...row }, { preserveInputs: true }))
}))
// Preserve row identity. QTable editors and the computed group totals must
// observe the exact same reactive row objects. Replacing rows with
// `{ ...row }` after the async exchange-rate request left QTable editing an
// older object while totals watched the replacement object.
detailGroups.value.forEach(grp => {
const items = Array.isArray(grp?.items) ? grp.items : []
items.forEach(row => {
recalculateDetailRow(row, { preserveInputs: true })
})
})
}
function resolveRowTRYTutar (row) {
const tryAmount = Number(row?.lTutar || 0)
return Number.isFinite(tryAmount) ? tryAmount : 0
const quantity = resolveNumericRowQuantity(row)
const inputPrice = resolveNumericRowInputPrice(row)
const inputCurrency = resolveInputCurrency(row)
const liveAmount = resolveTRYUnitPriceByInput(
inputPrice,
inputCurrency,
resolveExchangeRateValue('USD'),
resolveExchangeRateValue('EUR'),
resolveExchangeRateValue('GBP')
) * quantity
if (Number.isFinite(liveAmount) && inputPrice > 0) return liveAmount
return parseMoneyInput(row?.lTutar)
}
function resolveRowEURTutar (row) {
const eurAmount = Number(row?.eurTutar || 0)
return Number.isFinite(eurAmount) ? eurAmount : 0
return parseMoneyInput(row?.eurTutar)
}
function resolveRowGBPTutar (row) {
const gbpAmount = Number(row?.gbpTutar || 0)
return Number.isFinite(gbpAmount) ? gbpAmount : 0
return parseMoneyInput(row?.gbpTutar)
}
function resolveRowUSDTutar (row) {
const usdAmount = Number(row?.usdTutar || 0)
if (Number.isFinite(usdAmount)) return usdAmount
const quantity = resolveNumericRowQuantity(row)
const inputPrice = resolveNumericRowInputPrice(row)
const inputCurrency = resolveInputCurrency(row)
const liveAmount = resolveUSDUnitPriceByInput(
inputPrice,
inputCurrency,
resolveExchangeRateValue('USD'),
resolveExchangeRateValue('EUR'),
resolveExchangeRateValue('GBP')
) * quantity
const miktar = resolveNumericRowQuantity(row)
const dovizFiyati = Number(row?.lDovizFiyati || 0)
const calc = miktar * dovizFiyati
if (Number.isFinite(liveAmount) && inputPrice > 0) return liveAmount
const rawUSDAmount = row?.usdTutar
if (rawUSDAmount !== undefined && rawUSDAmount !== null && String(rawUSDAmount).trim() !== '') {
return parseMoneyInput(rawUSDAmount)
}
const dovizFiyati = parseMoneyInput(row?.lDovizFiyati)
const calc = quantity * dovizFiyati
return Number.isFinite(calc) ? calc : 0
}
function shouldIgnoreGroupMaliyeteDahil (grp) {
return isCMGroupName(grp?.sAciklama3)
}
function shouldIncludeRowInGroupTotal (grp, row) {
return shouldIgnoreGroupMaliyeteDahil(grp) || normalizeBooleanFlag(row?.maliyeteDahil)
if (!row) return false
// CM1/CM2 sub-headers always show their full group value. For every other
// group, the explicit "Maliyete Dahil" selection controls the subtotal.
if (isCMGroupName(grp?.sAciklama3 || row?.sAciklama3)) return true
const inclusionValue = row?.maliyeteDahil ?? row?.maliyete_dahil ?? row?.Maliyete_dahil
if (inclusionValue === false || inclusionValue === 0) return false
const normalizedValue = String(inclusionValue ?? '').trim().toLowerCase()
return normalizedValue !== '0' && normalizedValue !== 'false' && normalizedValue !== 'hayir'
}
function resolveGroupTRYTutar (grp) {
const items = Array.isArray(grp?.items) ? grp.items : []
return items.reduce((acc, row) => acc + (shouldIncludeRowInGroupTotal(grp, row) ? resolveRowTRYTutar(row) : 0), 0)
function resolveGroupRowTRYAmount (row) {
const columnAmount = parseMoneyInput(row?.lTutar)
if (columnAmount !== 0) return columnAmount
const liveAmount = resolveRowTRYTutar(row)
if (liveAmount !== 0) return liveAmount
return parseMoneyInput(row?.__lastValidTRYAmount ?? row?.lTutarTL)
}
function resolveGroupUSDTutar (grp) {
const items = Array.isArray(grp?.items) ? grp.items : []
return items.reduce((acc, row) => acc + (shouldIncludeRowInGroupTotal(grp, row) ? resolveRowUSDTutar(row) : 0), 0)
function resolveGroupRowUSDAmount (row) {
const columnAmount = parseMoneyInput(row?.usdTutar ?? row?.lTutarUSD)
if (columnAmount !== 0) return columnAmount
const liveAmount = resolveRowUSDTutar(row)
if (liveAmount !== 0) return liveAmount
return parseMoneyInput(row?.__lastValidUSDAmount)
}
// OrderEntry groupedRows ile ayni desen: sub-header degerleri ayri bir
// state/cache icinde tutulmaz; iki gorunen tutar kolonu satirlardan reaktif
// olarak her degisiklikte yeniden toplanir.
const detailGroupsWithTotals = computed(() => (
detailGroups.value.map(group => {
const items = Array.isArray(group?.items) ? group.items : []
const totals = items.reduce((acc, row) => {
if (!shouldIncludeRowInGroupTotal(group, row)) return acc
acc.tryTotal += resolveGroupRowTRYAmount(row)
acc.usdTotal += resolveGroupRowUSDAmount(row)
return acc
}, { tryTotal: 0, usdTotal: 0 })
return {
...group,
tryTotal: totals.tryTotal,
usdTotal: totals.usdTotal
}
})
))
function resolveGroupQuantity (grp) {
const items = Array.isArray(grp?.items) ? grp.items : []
return items.reduce((acc, row) => acc + (Number(row?.lMiktar || 0) || 0), 0)
return (Array.isArray(grp?.items) ? grp.items : []).reduce((total, row) => (
total + resolveNumericRowQuantity(row)
), 0)
}
function groupKey (grp, gi) {
@@ -3244,7 +3329,7 @@ function onRowQuantityInput (row, value) {
}
function triggerUIUpdate () {
detailGroups.value = [...detailGroups.value]
// detailGroupsWithTotals is computed from the reactive row fields.
}
function normalizeRowQuantityDisplay (row) {
@@ -3342,25 +3427,26 @@ async function fetchBulkItemPrices () {
}
let appliedCount = 0
detailGroups.value = detailGroups.value.map(grp => ({
...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => {
detailGroups.value.forEach(grp => {
const items = Array.isArray(grp?.items) ? grp.items : []
items.forEach(row => {
const match = updates.find(update => rowMatchesBulkUpdate(row, update))
if (!match) return row
if (!match) return
appliedCount += 1
return recalculateDetailRow({
...row,
inputPrice: match.inputPrice,
fiyat_girilen: match.fiyat_girilen,
inputPricePrBr: match.inputPricePrBr,
fiyat_doviz: match.fiyat_doviz
}, {
row.inputPrice = match.inputPrice
row.fiyat_girilen = match.fiyat_girilen
row.inputPricePrBr = match.inputPricePrBr
row.fiyat_doviz = match.fiyat_doviz
recalculateDetailRow(row, {
priceType: match.priceType || 'SAF',
updateState: 'bulk',
markChanged: true
})
})
}))
})
// Update sub-headers immediately after the bulk response is applied. Do
// not wait for the optional previous-cost autofill request below.
triggerUIUpdate()
$q.notify({
type: appliedCount > 0 ? 'positive' : 'warning',
@@ -3400,14 +3486,14 @@ async function fetchBulkItemPrices () {
})
let filled = 0
detailGroups.value = detailGroups.value.map(grp => ({
...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => {
if (!row?.requiredPlaceholder) return row
if (isCMGroupName(row?.sAciklama3) || normalizeGroupName(row?.sAciklama3) === 'FABRIC') return row
detailGroups.value.forEach(grp => {
const items = Array.isArray(grp?.items) ? grp.items : []
items.forEach(row => {
if (!row?.requiredPlaceholder) return
if (isCMGroupName(row?.sAciklama3) || normalizeGroupName(row?.sAciklama3) === 'FABRIC') return
const no = parseInt(String(row?.nHammaddeTuruNo || '').trim() || '0', 10) || 0
const hit = byNo[no]
if (!hit) return row
if (!hit) return
const next = { ...row }
const hasCode = String(next.sKodu || '').trim() !== ''
@@ -3427,20 +3513,20 @@ async function fetchBulkItemPrices () {
// Only mark if something changed
const changed = (next.sKodu !== row.sKodu) || (next.sAciklama !== row.sAciklama) || (next.inputPrice !== row.inputPrice) || (next.inputPricePrBr !== row.inputPricePrBr)
if (!changed) return row
if (!changed) return
filled += 1
return recalculateDetailRow({
...next,
__autoFilledFromPrev: true
,
Object.assign(row, next, {
__autoFilledFromPrev: true,
__autoFilledFromPrevSameFirma: Boolean(hit?.is_same_firma || hit?.isSameFirma)
}, {
})
recalculateDetailRow(row, {
priceType: 'PREV',
updateState: 'autofill',
markChanged: true
})
})
}))
})
triggerUIUpdate()
if (filled > 0) {
$q.notify({
@@ -3457,6 +3543,11 @@ async function fetchBulkItemPrices () {
error: String(e?.message || e || '')
})
}
// Bulk price and previous-cost autofill paths can update many rows at once.
// Recalculate once more as a whole so group sub-header totals react immediately.
recalculateAllDetailRows()
schedulePersistLocalDraft()
triggerUIUpdate()
} catch (err) {
$q.notify({
type: 'negative',
@@ -3928,10 +4019,14 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) {
const groupName = normalizeGroupName(meta?.groupName || '')
const hammaddeAdi = String(meta?.hammaddeAdi || '').trim()
const effectiveGroupName = groupName || 'TANIMSIZ'
const mtBolumID = (meta?.mtBolumID > 0 ? meta.mtBolumID : mappingMtBolumID) || 0
const metaParcaCandidate = normalizeGroupName((meta?.parcaAdi || '').trim())
// Defensive: if backend/lookup accidentally returns group label (DT/TP/...) as part name, ignore it.
const desiredParcaAdi = normalizeGroupName((metaParcaCandidate && !isKnownGroupName(metaParcaCandidate)) ? metaParcaCandidate : mappingParcaAdi)
// The product-type mapping is authoritative for the required part. A raw
// material type can be required for multiple parts, while its master
// record only carries one default MT section. Using that default here
// incorrectly made an existing recipe row satisfy every mapped part.
const mtBolumID = mappingMtBolumID > 0 ? mappingMtBolumID : (meta?.mtBolumID || 0)
const desiredParcaAdi = mappingMtBolumID > 0
? mappingParcaAdi
: normalizeGroupName(meta?.parcaAdi || mappingParcaAdi)
const anyMatch = flatDetailRows.value.find(r => {
if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false
@@ -5062,13 +5157,31 @@ async function saveChanges () {
// If we created a new OnML (no-cost), switch to has-cost detail mode.
if (isNoCostDetail.value && newOnMLNo > 0) {
router.replace({
await router.replace({
name: 'production-product-costing-has-cost-detail',
query: {
n_onml_no: String(newOnMLNo),
urun_kodu: String(header?.UrunKodu || productCode.value || '').trim()
}
})
$q.dialog({
title: 'Maliyet Kaydedildi',
message: 'Maliyeti olmayan urunler listesine donup kaldiginiz yerden devam etmek ister misiniz?',
ok: {
label: 'Listeye Don',
color: 'primary',
icon: 'arrow_back'
},
cancel: {
label: 'Bu Sayfada Devam Et',
color: 'grey-7',
flat: true
},
persistent: true
}).onOk(() => {
router.push({ name: 'production-product-costing-no-cost' })
})
return
}
@@ -5078,6 +5191,24 @@ async function saveChanges () {
window.setTimeout(() => {
try { refreshLast10Warnings() } catch {}
}, 1200)
$q.dialog({
title: 'Maliyet Guncellendi',
message: 'Mevcut maliyeti olan urunler listesine donup kaldiginiz yerden devam etmek ister misiniz?',
ok: {
label: 'Listeye Don',
color: 'primary',
icon: 'arrow_back'
},
cancel: {
label: 'Bu Sayfada Devam Et',
color: 'grey-7',
flat: true
},
persistent: true
}).onOk(() => {
router.push({ name: 'production-product-costing-has-cost' })
})
} catch (e) {
// Surface backend message (http.Error text) when available.
const msg = String(
@@ -5526,8 +5657,8 @@ watch(
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
min-height: 42px;
height: 42px;
min-height: 46px;
height: auto;
border-top: 1px solid #d6c06a;
border-bottom: 1px solid #d6c06a;
background: linear-gradient(90deg, #fffbe9 0%, #fff4c4 50%, #fff1b0 100%);
@@ -5549,7 +5680,9 @@ watch(
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
flex-wrap: wrap;
row-gap: 4px;
max-width: 70%;
}
.pcd-sub-header .sub-left {
display: flex;
@@ -5566,8 +5699,31 @@ watch(
user-select: none;
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
gap: 6px 10px;
flex-wrap: wrap;
}
.pcd-group-total-text {
color: #2f2408;
font-size: 13px;
font-weight: 900;
white-space: nowrap;
}
.pcd-group-total-divider {
margin: 0 4px;
color: #8d6d13;
}
.pcd-group-debug {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px;
border: 1px solid rgba(180, 35, 24, 0.35);
border-radius: 5px;
color: #8f1d14;
background: rgba(255, 255, 255, 0.78);
font-size: 11px;
font-weight: 800;
text-transform: none;
white-space: nowrap;
}
.pcd-sub-mt-qty {
@@ -5577,6 +5733,18 @@ watch(
font-weight: 900;
}
@media (max-width: 900px) {
.pcd-sub-header {
align-items: flex-start;
}
.pcd-sub-header .sub-right {
max-width: 78%;
}
.pcd-group-debug {
flex-basis: auto;
}
}
.pcd-detail-table :deep(.q-table__middle) {
overflow: visible !important;
}
@@ -5717,12 +5885,69 @@ watch(
}
.pcd-history-table :deep(.q-table thead th) {
font-size: 12px;
font-size: 9.5px;
line-height: 1.1;
white-space: normal;
padding: 3px 2px;
}
.pcd-history-table :deep(.q-table tbody td) {
font-size: 12px;
font-size: 9.5px;
vertical-align: top;
padding: 2px;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.pcd-history-table :deep(.q-table__middle) {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.pcd-history-table :deep(.q-table) {
table-layout: fixed;
width: 100%;
min-width: 0;
}
.pcd-history-table :deep(.q-table th),
.pcd-history-table :deep(.q-table td) {
min-width: 0 !important;
}
.pcd-history-table :deep(.q-table th:nth-child(1)), .pcd-history-table :deep(.q-table td:nth-child(1)) { width: 5% !important; }
.pcd-history-table :deep(.q-table th:nth-child(2)), .pcd-history-table :deep(.q-table td:nth-child(2)) { width: 6% !important; }
.pcd-history-table :deep(.q-table th:nth-child(3)), .pcd-history-table :deep(.q-table td:nth-child(3)) { width: 7% !important; }
.pcd-history-table :deep(.q-table th:nth-child(4)), .pcd-history-table :deep(.q-table td:nth-child(4)) { width: 6% !important; }
.pcd-history-table :deep(.q-table th:nth-child(5)), .pcd-history-table :deep(.q-table td:nth-child(5)) { width: 8% !important; }
.pcd-history-table :deep(.q-table th:nth-child(6)), .pcd-history-table :deep(.q-table td:nth-child(6)) { width: 7% !important; }
.pcd-history-table :deep(.q-table th:nth-child(7)), .pcd-history-table :deep(.q-table td:nth-child(7)) { width: 10% !important; }
.pcd-history-table :deep(.q-table th:nth-child(8)), .pcd-history-table :deep(.q-table td:nth-child(8)) { width: 5% !important; }
.pcd-history-table :deep(.q-table th:nth-child(9)), .pcd-history-table :deep(.q-table td:nth-child(9)) { width: 7% !important; }
.pcd-history-table :deep(.q-table th:nth-child(10)), .pcd-history-table :deep(.q-table td:nth-child(10)) { width: 5% !important; }
.pcd-history-table :deep(.q-table th:nth-child(11)), .pcd-history-table :deep(.q-table td:nth-child(11)) { width: 7% !important; }
.pcd-history-table :deep(.q-table th:nth-child(12)), .pcd-history-table :deep(.q-table td:nth-child(12)) { width: 5% !important; }
.pcd-history-table :deep(.q-table th:nth-child(13)), .pcd-history-table :deep(.q-table td:nth-child(13)) { width: 3% !important; }
.pcd-history-table :deep(.q-table th:nth-child(14)), .pcd-history-table :deep(.q-table td:nth-child(14)) { width: 5% !important; }
.pcd-history-table :deep(.q-table th:nth-child(15)), .pcd-history-table :deep(.q-table td:nth-child(15)) { width: 6% !important; }
.pcd-history-table :deep(.q-table th:nth-child(16)), .pcd-history-table :deep(.q-table td:nth-child(16)) { width: 4% !important; }
.pcd-history-table :deep(.q-table th:nth-child(17)), .pcd-history-table :deep(.q-table td:nth-child(17)) { width: 4% !important; }
.pcd-history-table :deep(.pcd-history-source-chip) {
min-width: 0;
max-width: 100%;
padding: 2px 4px;
font-size: 9px;
}
.pcd-history-table :deep(.q-btn) {
min-width: 0;
padding-left: 5px;
padding-right: 5px;
font-size: 9px;
}
.pcd-history-table :deep(.pcd-history-row-purchase td) {
+102 -4
View File
@@ -49,6 +49,14 @@
:loading="loading"
@click="fetchRows"
/>
<q-btn
label="Excel'e Aktar"
icon="download"
color="primary"
outline
:disable="loading || rows.length === 0"
@click="exportVisibleRows"
/>
<div class="npc-missing-count text-caption text-grey-7">
Maliyeti Girilmemis Satir Sayisi: <b>{{ missingCostRowCount }}</b>
</div>
@@ -176,7 +184,7 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router'
import { usePermission } from 'src/composables/usePermission'
@@ -244,6 +252,9 @@ const columns = [
style: 'width:7%',
headerStyle: 'width:7%'
},
{ name: 'ceketDepoGiris', label: 'C-Ceket Depo Giris', field: 'ceketDepoGiris', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width:7%', headerStyle: 'width:7%' },
{ name: 'pantolonDepoGiris', label: 'P-Pantolon Depo Giris', field: 'pantolonDepoGiris', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width:7%', headerStyle: 'width:7%' },
{ name: 'yelekDepoGiris', label: 'Y-Yelek Depo Giris', field: 'yelekDepoGiris', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width:7%', headerStyle: 'width:7%' },
{
name: 'sMModelKodu',
label: 'Model Kodu',
@@ -262,6 +273,34 @@ const columns = [
]
const columnFilters = reactive({})
const LIST_STATE_STORAGE_KEY = 'bssapp:production-product-costing:no-cost-list-state:v1'
function persistListState () {
try {
sessionStorage.setItem(LIST_STATE_STORAGE_KEY, JSON.stringify({
filters: {
search: String(filters.search || ''),
fromDate: String(filters.fromDate || '')
},
columnFilters: JSON.parse(JSON.stringify(columnFilters)),
scrollY: Number(window.scrollY || 0)
}))
} catch {}
}
function restoreListState () {
try {
const raw = sessionStorage.getItem(LIST_STATE_STORAGE_KEY)
if (!raw) return 0
const saved = JSON.parse(raw)
filters.search = String(saved?.filters?.search || '')
filters.fromDate = String(saved?.filters?.fromDate || '2025-06-01')
Object.assign(columnFilters, saved?.columnFilters && typeof saved.columnFilters === 'object' ? saved.columnFilters : {})
return Number(saved?.scrollY || 0)
} catch {
return 0
}
}
function getColumnFilter (name) {
if (!columnFilters[name]) {
@@ -276,7 +315,11 @@ function getColumnFilter (name) {
}
function isNumericColumn (name) {
return name === 'lMMiktar_G'
return ['lMMiktar_G', 'ceketDepoGiris', 'pantolonDepoGiris', 'yelekDepoGiris'].includes(name)
}
function formatQuantity (value) {
return Number(value || 0).toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function parseNumberFilter (value) {
@@ -384,9 +427,11 @@ function clearAllColumnFilters () {
}
let searchTimer = null
let filterWatchEnabled = false
watch(
() => filters.search,
() => {
if (!filterWatchEnabled) return
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
fetchRows()
@@ -397,6 +442,7 @@ watch(
watch(
() => filters.fromDate,
() => {
if (!filterWatchEnabled) return
fetchRows()
}
)
@@ -430,6 +476,49 @@ function clearFilters () {
fetchRows()
}
function escapeExcelCsvCell (value) {
let text = String(value ?? '')
// Prevent spreadsheet applications from evaluating data as a formula.
if (/^[=+\-@]/.test(text)) text = `'${text}`
return `"${text.replace(/"/g, '""')}"`
}
function formatExcelCsvCell (col, rawValue, displayValue) {
if (isNumericColumn(col?.name)) {
const numericValue = Number(rawValue)
if (Number.isFinite(numericValue)) return String(numericValue).replace('.', ',')
}
return escapeExcelCsvCell(displayValue)
}
function exportVisibleRows () {
const visibleRows = Array.isArray(rows.value) ? rows.value : []
if (visibleRows.length === 0) {
$q.notify({ type: 'warning', message: 'Excel aktarimi icin ekranda satir bulunamadi.', position: 'top-right' })
return
}
const exportColumns = columns.filter(col => col.name !== 'open')
const csvLines = [
exportColumns.map(col => escapeExcelCsvCell(col.label)).join(';'),
...visibleRows.map(row => exportColumns.map(col => {
const rawValue = typeof col.field === 'function' ? col.field(row) : row?.[col.field]
const displayValue = typeof col.format === 'function' ? col.format(rawValue, row) : rawValue
return formatExcelCsvCell(col, rawValue, displayValue)
}).join(';'))
]
const blob = new Blob([`\uFEFF${csvLines.join('\r\n')}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `maliyeti_olmayan_urunler_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
function openRow (row) {
const productCode = String(row?.sMModelKodu || '').trim()
const recipeCode = String(row?.sKodu || '').trim()
@@ -449,6 +538,8 @@ function openRow (row) {
recipe_code: recipeCode
})
persistListState()
router.push({
name: 'production-product-costing-has-cost-detail',
query: {
@@ -460,9 +551,16 @@ function openRow (row) {
})
}
onMounted(() => {
onMounted(async () => {
if (!canReadOrder.value) return
fetchRows()
const savedScrollY = restoreListState()
await nextTick()
filterWatchEnabled = true
await fetchRows()
if (savedScrollY > 0) {
await nextTick()
window.scrollTo({ top: savedScrollY, behavior: 'auto' })
}
})
</script>
+88 -28
View File
@@ -4,37 +4,38 @@ import api from 'src/services/api'
const GROUPED_TTL_MS = 2 * 60 * 1000
const IMAGE_TTL_MS = 15 * 60 * 1000
function normalizeUploadsPath (storagePath) {
const raw = String(storagePath || '').trim()
if (!raw) return ''
const normalized = raw.replace(/\\/g, '/')
const idx = normalized.toLowerCase().indexOf('/uploads/')
if (idx >= 0) return normalized.slice(idx)
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
return ''
function resolveProductImageUrl (item) {
return resolveProductImageUrls(item)[0] || ''
}
function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return ''
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) return contentURL
if (contentURL.startsWith('/')) return `/api${contentURL}`
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
function resolveProductImageUrls (item) {
if (!item || typeof item !== 'object') return []
const urls = []
const addURL = value => {
const url = String(value || '').trim()
if (url && !urls.includes(url)) urls.push(url)
}
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
if (thumbURL) return thumbURL
addURL(thumbURL)
const fullURL = String(item.full_url || item.fullUrl || '').trim()
if (fullURL) return fullURL
addURL(fullURL)
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
if (uploadsPath) return uploadsPath
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) addURL(contentURL)
else if (contentURL.startsWith('/')) addURL(`/api${contentURL}`)
return urls
}
const fileName = String(item.file_name || item.FileName || '').trim()
return fileName ? `/uploads/image/${fileName}` : ''
function normalizedFilterMap (filters = {}) {
return Object.fromEntries(Object.entries(filters || {})
.map(([key, values]) => [
String(key || '').trim(),
normalizedList(values, true)
])
.filter(([key, values]) => key && values.length)
.sort((a, b) => a[0].localeCompare(b[0], 'tr')))
}
function normalizedList (value, sort = false) {
@@ -49,6 +50,9 @@ export const useProductPerformanceStore = defineStore('product-performance-store
groupedLoadedAtByKey: {},
groupedLoadingByKey: {},
groupedInFlightByKey: {},
groupedFilterOptionsByKey: {},
groupedFilterOptionsLoadedAtByKey: {},
groupedFilterOptionsInFlightByKey: {},
imageUrlByKey: {},
imageListByKey: {},
imageLoadedAtByKey: {}
@@ -65,8 +69,23 @@ export const useProductPerformanceStore = defineStore('product-performance-store
groupedCacheKey (params = {}) {
return JSON.stringify({
mode: String(params.mode || ''),
mainGroup: String(params.urunAnaGrubu || params.urun_ana_grubu || ''),
groupLevels: normalizedList(params.groupLevels),
expandedKeys: normalizedList(params.expandedKeys, true),
expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
filters: normalizedFilterMap(params.filters),
sortBy: String(params.sortBy || params.sort_by || ''),
descending: params.descending !== false,
limit: Number(params.limit || 0)
})
},
groupedFilterOptionsCacheKey (params = {}) {
return JSON.stringify({
mode: String(params.mode || ''),
mainGroup: String(params.urunAnaGrubu || params.urun_ana_grubu || ''),
groupLevels: normalizedList(params.groupLevels),
fields: normalizedList(params.fields, true),
limit: Number(params.limit || 0)
})
},
@@ -89,7 +108,8 @@ export const useProductPerformanceStore = defineStore('product-performance-store
console.info(`${logPrefix} in-flight reuse`, {
mode: params.mode,
groupLevels: normalizedList(params.groupLevels),
expandedKeys: normalizedList(params.expandedKeys, true).length
expandedKeys: normalizedList(params.expandedKeys, true).length,
expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1)
})
return this.groupedInFlightByKey[key]
}
@@ -100,17 +120,27 @@ export const useProductPerformanceStore = defineStore('product-performance-store
mode: params.mode,
groupLevels: normalizedList(params.groupLevels),
expandedKeys: normalizedList(params.expandedKeys, true).length,
expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
limit: params.limit
})
const request = api.post('/pricing/product-performance/grouped', {
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),
expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys : String(params.expandedKeys || '').split(',').filter(Boolean),
expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
filters: normalizedFilterMap(params.filters),
sort_by: String(params.sortBy || params.sort_by || ''),
descending: params.descending !== false,
limit: params.limit
}, {
params: {
mode: params.mode,
limit: params.limit
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
}).then(resp => {
@@ -124,10 +154,11 @@ export const useProductPerformanceStore = defineStore('product-performance-store
})
return rows
}).catch(err => {
console.warn(`${logPrefix} request failed`, {
const message = err?.response?.data || err?.message || String(err || '')
console.warn(`${logPrefix} request failed: ${message}`, {
mode: params.mode,
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)),
message: err?.response?.data || err?.message || err
message
})
throw err
}).finally(() => {
@@ -143,6 +174,35 @@ export const useProductPerformanceStore = defineStore('product-performance-store
return request
},
async fetchGroupedFilterOptions (params = {}, options = {}) {
const key = this.groupedFilterOptionsCacheKey(params)
const now = Date.now()
const loadedAt = Number(this.groupedFilterOptionsLoadedAtByKey[key] || 0)
const cached = this.groupedFilterOptionsByKey[key]
if (!options.force && cached && now - loadedAt < GROUPED_TTL_MS) return cached
if (this.groupedFilterOptionsInFlightByKey[key]) return this.groupedFilterOptionsInFlightByKey[key]
const request = api.post('/pricing/product-performance/grouped/filter-options', {
mode: params.mode,
urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '',
group_levels: Array.isArray(params.groupLevels) ? params.groupLevels : String(params.groupLevels || '').split(',').filter(Boolean),
fields: Array.isArray(params.fields) ? params.fields : String(params.fields || '').split(',').filter(Boolean),
limit: params.limit || 50000
}, { timeout: 60000 }).then(resp => {
const data = resp?.data && typeof resp.data === 'object' ? resp.data : {}
this.groupedFilterOptionsByKey = { ...this.groupedFilterOptionsByKey, [key]: data }
this.groupedFilterOptionsLoadedAtByKey = { ...this.groupedFilterOptionsLoadedAtByKey, [key]: Date.now() }
return data
}).finally(() => {
const nextInFlight = { ...this.groupedFilterOptionsInFlightByKey }
delete nextInFlight[key]
this.groupedFilterOptionsInFlightByKey = nextInFlight
})
this.groupedFilterOptionsInFlightByKey = { ...this.groupedFilterOptionsInFlightByKey, [key]: request }
return request
},
setImageCache (key, urls = []) {
const normalizedKey = String(key || '').trim()
if (!normalizedKey) return
@@ -186,7 +246,7 @@ export const useProductPerformanceStore = defineStore('product-performance-store
const key = String(item?.key || '').trim()
if (!key) continue
returned.add(key)
const urls = (Array.isArray(item.images) ? item.images : []).map(resolveProductImageUrl).filter(Boolean)
const urls = (Array.isArray(item.images) ? item.images : []).flatMap(resolveProductImageUrls).filter(Boolean)
this.setImageCache(key, urls)
lists[key] = urls
urlsByKey[key] = urls[0] || ''