Compare commits

...
27 Commits
Author SHA1 Message Date
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
20 changed files with 2172 additions and 576 deletions
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4"> <module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" /> <component name="Go" enabled="true" />
<component name="GoModuleSettings" enabled="true" />
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
+2
View File
@@ -3,6 +3,8 @@ bssapp
# Env # Env
.env .env
.env.local
.env.argebaggi.local
mail.env mail.env
# Runtime fonts # Runtime fonts
+39
View File
@@ -14,6 +14,7 @@ import (
var MssqlDB *sql.DB var MssqlDB *sql.DB
var UretimDB *sql.DB var UretimDB *sql.DB
var ArgeBaggiDB *sql.DB
func envInt(name string, fallback int) int { func envInt(name string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(name)) raw := strings.TrimSpace(os.Getenv(name))
@@ -158,3 +159,41 @@ func ConnectMSSQLUretim() error {
func GetUretimDB() *sql.DB { func GetUretimDB() *sql.DB {
return UretimDB return UretimDB
} }
// ConnectMSSQLArgeBaggi initializes the optional ArgeBAGGI connection.
// Credentials must be supplied through ARGEBAGGI_MSSQL_CONN; never keep them
// in source control.
func ConnectMSSQLArgeBaggi() error {
connString := strings.TrimSpace(os.Getenv("ARGEBAGGI_MSSQL_CONN"))
if connString == "" {
return fmt.Errorf("ARGEBAGGI_MSSQL_CONN tanimli degil")
}
connectionTimeoutSec := envInt("ARGEBAGGI_MSSQL_CONNECTION_TIMEOUT_SEC", 10)
dialTimeoutSec := envInt("ARGEBAGGI_MSSQL_DIAL_TIMEOUT_SEC", connectionTimeoutSec)
connString = ensureMSSQLTimeouts(connString, connectionTimeoutSec, dialTimeoutSec)
var err error
ArgeBaggiDB, err = sql.Open("sqlserver", connString)
if err != nil {
return fmt.Errorf("ArgeBAGGI MSSQL baglanti hatasi: %w", err)
}
ArgeBaggiDB.SetMaxOpenConns(envInt("ARGEBAGGI_MSSQL_MAX_OPEN_CONNS", 10))
ArgeBaggiDB.SetMaxIdleConns(envInt("ARGEBAGGI_MSSQL_MAX_IDLE_CONNS", 5))
ArgeBaggiDB.SetConnMaxLifetime(time.Duration(envInt("ARGEBAGGI_MSSQL_CONN_MAX_LIFETIME_MIN", 30)) * time.Minute)
ArgeBaggiDB.SetConnMaxIdleTime(time.Duration(envInt("ARGEBAGGI_MSSQL_CONN_MAX_IDLE_MIN", 10)) * time.Minute)
if err = ArgeBaggiDB.Ping(); err != nil {
_ = ArgeBaggiDB.Close()
ArgeBaggiDB = nil
return fmt.Errorf("ArgeBAGGI MSSQL erisilemiyor: %w", err)
}
fmt.Printf("ArgeBAGGI MSSQL baglantisi basarili (connection timeout=%ds, dial timeout=%ds)\n", connectionTimeoutSec, dialTimeoutSec)
return nil
}
func GetArgeBaggiDB() *sql.DB {
return ArgeBaggiDB
}
+31
View File
@@ -227,6 +227,7 @@ InitRoutes — FULL V3 (Method-aware) PERMISSION EDITION
func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router { func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router {
r := mux.NewRouter() r := mux.NewRouter()
mountUploads(r)
mountSPA(r) mountSPA(r)
/* /*
@@ -1389,6 +1390,7 @@ func main() {
// (e.g. SSH-tunnel PostgreSQL on 127.0.0.1:15432). // (e.g. SSH-tunnel PostgreSQL on 127.0.0.1:15432).
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
_ = godotenv.Overload(".env.local") _ = godotenv.Overload(".env.local")
_ = godotenv.Overload(".env.argebaggi.local")
} }
jwtSecret := os.Getenv("JWT_SECRET") jwtSecret := os.Getenv("JWT_SECRET")
@@ -1416,6 +1418,15 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
} }
if err := db.ConnectMSSQLArgeBaggi(); err != nil {
if strings.Contains(err.Error(), "ARGEBAGGI_MSSQL_CONN tanimli degil") {
log.Println("ArgeBAGGI DB baglantisi atlandi: ARGEBAGGI_MSSQL_CONN tanimli degil")
} else {
// This source enriches one screen; a temporary outage must not stop
// the whole application from starting.
log.Println("ArgeBAGGI DB baglantisi basarisiz:", err)
}
}
pgDB, err := db.ConnectPostgres() pgDB, err := db.ConnectPostgres()
if err != nil { if err != nil {
@@ -1517,6 +1528,26 @@ func main() {
} }
func mountUploads(r *mux.Router) {
root := strings.TrimSpace(os.Getenv("BLOB_ROOT"))
if root == "" {
return
}
uploadsRoot := filepath.Join(root, "uploads")
if fi, err := os.Stat(uploadsRoot); err != nil || !fi.IsDir() {
return
}
fileServer := http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadsRoot)))
r.PathPrefix("/uploads/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Cache-Control", "public, max-age=3600")
fileServer.ServeHTTP(w, r)
})).Methods(http.MethodGet, http.MethodHead, http.MethodOptions)
}
func mountSPA(r *mux.Router) { func mountSPA(r *mux.Router) {
r.NotFoundHandler = http.HandlerFunc(spaIndex) r.NotFoundHandler = http.HandlerFunc(spaIndex)
r.HandleFunc("/", spaIndex).Methods(http.MethodGet) r.HandleFunc("/", spaIndex).Methods(http.MethodGet)
+21
View File
@@ -31,6 +31,10 @@ type ProductPerformanceRow struct {
AvgDailySales180 float64 `json:"avg_daily_sales_180d"` AvgDailySales180 float64 `json:"avg_daily_sales_180d"`
AvgDailySales365 float64 `json:"avg_daily_sales_365d"` AvgDailySales365 float64 `json:"avg_daily_sales_365d"`
AvgDailySalesTotal float64 `json:"avg_daily_sales_total"` AvgDailySalesTotal float64 `json:"avg_daily_sales_total"`
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"` StockDays90 float64 `json:"stock_days_90d"`
StockDays180 float64 `json:"stock_days_180d"` StockDays180 float64 `json:"stock_days_180d"`
StockDays365 float64 `json:"stock_days_365d"` StockDays365 float64 `json:"stock_days_365d"`
@@ -53,10 +57,23 @@ type ProductPerformanceRow struct {
UnitProfitBase90 float64 `json:"unit_profit_base_90d"` UnitProfitBase90 float64 `json:"unit_profit_base_90d"`
UnitProfitBase180 float64 `json:"unit_profit_base_180d"` UnitProfitBase180 float64 `json:"unit_profit_base_180d"`
MarketCount90 int `json:"market_count_90d"` 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"` 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"` 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"` PriceIndex90 float64 `json:"price_index_90d"`
MarginIndex90 float64 `json:"margin_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"` PerformanceScore float64 `json:"performance_score"`
PerformanceBucket string `json:"performance_bucket"` PerformanceBucket string `json:"performance_bucket"`
Recommendation string `json:"recommendation"` Recommendation string `json:"recommendation"`
@@ -378,6 +395,10 @@ type ProductPerformanceSalesBreakdownRow struct {
GrossMarginBaseTotal float64 `json:"gross_margin_base_total"` GrossMarginBaseTotal float64 `json:"gross_margin_base_total"`
GrossMarginCostTotal float64 `json:"gross_margin_cost_total"` GrossMarginCostTotal float64 `json:"gross_margin_cost_total"`
StockQty float64 `json:"stock_qty"` 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"` StockTurnover90 float64 `json:"stock_turnover_90d"`
StockTurnover180 float64 `json:"stock_turnover_180d"` StockTurnover180 float64 `json:"stock_turnover_180d"`
StockTurnover365 float64 `json:"stock_turnover_365d"` StockTurnover365 float64 `json:"stock_turnover_365d"`
+7
View File
@@ -12,11 +12,18 @@ type ProductionNoCostProductRow struct {
SKullaniciAdi string `json:"sKullaniciAdi"` SKullaniciAdi string `json:"sKullaniciAdi"`
SKullaniciGunc string `json:"sKullaniciAdiGunc"` SKullaniciGunc string `json:"sKullaniciAdiGunc"`
MMiktarG float64 `json:"lMMiktar_G"` MMiktarG float64 `json:"lMMiktar_G"`
CeketDepoGiris float64 `json:"ceketDepoGiris"`
PantolonDepoGiris float64 `json:"pantolonDepoGiris"`
YelekDepoGiris float64 `json:"yelekDepoGiris"`
MModelKodu string `json:"sMModelKodu"` MModelKodu string `json:"sMModelKodu"`
} }
type ProductionHasCostProductRow struct { type ProductionHasCostProductRow struct {
UretimSekli string `json:"UretimSekli"` UretimSekli string `json:"UretimSekli"`
UrtSiparisNo string `json:"nUrtSiparisNo"`
CeketDepoGiris float64 `json:"ceketDepoGiris"`
PantolonDepoGiris float64 `json:"pantolonDepoGiris"`
YelekDepoGiris float64 `json:"yelekDepoGiris"`
NOnMLNo string `json:"nOnMLNo"` NOnMLNo string `json:"nOnMLNo"`
UrunKodu string `json:"UrunKodu"` UrunKodu string `json:"UrunKodu"`
UrunAdi string `json:"UrunAdi"` UrunAdi string `json:"UrunAdi"`
+820 -203
View File
@@ -481,6 +481,10 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
sales_usd_365d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_usd_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0, avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0, avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_stock_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_stock_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_stock_365d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_stock_total NUMERIC(18,6) NOT NULL DEFAULT 0,
stock_days_90d NUMERIC(18,6) NOT NULL DEFAULT 0, stock_days_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0, stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_price_usd_90d NUMERIC(18,6) NOT NULL DEFAULT 0, avg_price_usd_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
@@ -497,8 +501,17 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0, unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0, unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
market_count_90d INTEGER NOT NULL DEFAULT 0, market_count_90d INTEGER NOT NULL DEFAULT 0,
market_count_180d INTEGER NOT NULL DEFAULT 0,
market_count_365d INTEGER NOT NULL DEFAULT 0,
market_count_total INTEGER NOT NULL DEFAULT 0,
customer_count_90d INTEGER NOT NULL DEFAULT 0, customer_count_90d INTEGER NOT NULL DEFAULT 0,
customer_count_180d INTEGER NOT NULL DEFAULT 0,
customer_count_365d INTEGER NOT NULL DEFAULT 0,
customer_count_total INTEGER NOT NULL DEFAULT 0,
sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
sales_index_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
sales_index_365d NUMERIC(18,6) NOT NULL DEFAULT 0,
sales_index_total NUMERIC(18,6) NOT NULL DEFAULT 0,
price_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, price_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
margin_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, margin_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
performance_score NUMERIC(18,6) NOT NULL DEFAULT 0, performance_score NUMERIC(18,6) NOT NULL DEFAULT 0,
@@ -552,6 +565,10 @@ WHERE btrim(askili_yan) = '-'`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_365d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_total NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_365d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_total NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
@@ -567,6 +584,15 @@ WHERE btrim(askili_yan) = '-'`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_90d INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_90d INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_180d INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_365d INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_total INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS customer_count_180d INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS customer_count_365d INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS customer_count_total INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_index_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_index_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_index_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
` `
CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot ( CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot (
report_key TEXT NOT NULL, report_key TEXT NOT NULL,
@@ -1072,6 +1098,9 @@ func RebuildProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB)
return 0, err return 0, err
} }
log.Printf("[ProductPerformanceRefresh] grouped snapshot definitions ready count=%d", len(defs)) log.Printf("[ProductPerformanceRefresh] grouped snapshot definitions ready count=%d", len(defs))
if err := deleteStaleProductPerformanceGroupedSnapshots(ctx, pg, productPerformanceSnapshotKey("grouped", "product_detail")+":%"); err != nil {
return 0, err
}
total := 0 total := 0
for i, def := range defs { for i, def := range defs {
started := time.Now() started := time.Now()
@@ -1107,41 +1136,33 @@ func RebuildProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB)
return total, nil return total, nil
} }
func deleteStaleProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB, reportKeyLike string) error {
reportKeyLike = strings.TrimSpace(reportKeyLike)
if reportKeyLike == "" {
return nil
}
if _, err := pg.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot WHERE report_key LIKE $1`, reportKeyLike); err != nil {
return err
}
if _, err := pg.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot_meta WHERE report_key LIKE $1`, reportKeyLike); err != nil {
return err
}
return nil
}
func productPerformanceGroupedSnapshotDefinitions(ctx context.Context, pg *sql.DB) ([]productPerformanceGroupedSnapshotDefinition, error) { func productPerformanceGroupedSnapshotDefinitions(ctx context.Context, pg *sql.DB) ([]productPerformanceGroupedSnapshotDefinition, error) {
_ = ctx
_ = pg
defs := []productPerformanceGroupedSnapshotDefinition{ defs := []productPerformanceGroupedSnapshotDefinition{
{Mode: "products", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key"}}, {Mode: "products", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key"}},
{Mode: "products", Levels: []string{"urun_ana_grubu"}}, {Mode: "products", Levels: []string{"urun_ana_grubu"}},
{Mode: "idle", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}}, {Mode: "idle", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
{Mode: "sales_color_yaka_market_customer", Levels: []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "country", "market_key", "customer_segment", "customer_code", "customer_name"}}, {Mode: "sales_color_yaka_market_customer", Levels: []string{"urun_ana_grubu", "color_yaka", "market_key", "customer_code", "customer_name", "urun_alt_grubu", "product_code"}},
{Mode: "sales_product_country_segment_market_customer", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "country", "customer_segment", "market_key", "customer_code", "customer_name"}}, {Mode: "sales_product_country_segment_market_customer", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "country", "customer_segment", "market_key", "customer_code", "customer_name"}},
{Mode: "sales_country_segment_market_customer_product", Levels: []string{"country", "customer_segment", "market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}}, {Mode: "sales_country_segment_market_customer_product", Levels: []string{"country", "customer_segment", "market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
{Mode: "order_product_customers", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key", "customer_code", "customer_name"}}, {Mode: "order_product_customers", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key", "customer_code", "customer_name"}},
{Mode: "order_market_details", Levels: []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}}, {Mode: "order_market_details", Levels: []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
} }
productRows, ok, err := loadProductPerformanceSnapshotMapRows(ctx, pg, productPerformanceSnapshotKey("products"), 50000)
if err != nil {
return nil, err
}
if ok {
mainGroups := make([]string, 0)
seen := map[string]bool{}
for _, row := range productRows {
mainGroup := productPerformanceGroupedFilterValue(row, "urun_ana_grubu")
if mainGroup == "" || seen[mainGroup] {
continue
}
seen[mainGroup] = true
mainGroups = append(mainGroups, mainGroup)
}
sort.Strings(mainGroups)
for _, mainGroup := range mainGroups {
defs = append(defs, productPerformanceGroupedSnapshotDefinition{
Mode: "product_detail",
Levels: []string{"urun_alt_grubu", "product_code", "color_yaka", "market_key"},
MainGroup: mainGroup,
})
}
}
return defs, nil return defs, nil
} }
@@ -1775,11 +1796,15 @@ SELECT
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total, sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total,
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total, sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total,
avg_daily_sales_90d, avg_daily_sales_180d, avg_daily_sales_365d, avg_daily_sales_total, avg_daily_sales_90d, avg_daily_sales_180d, avg_daily_sales_365d, avg_daily_sales_total,
avg_stock_90d, avg_stock_180d, avg_stock_365d, avg_stock_total,
stock_days_90d, stock_days_180d, stock_days_365d, stock_days_total, stock_days_90d, stock_days_180d, stock_days_365d, stock_days_total,
stock_turnover_90d, stock_turnover_180d, stock_turnover_365d, stock_turnover_total, stock_turnover_90d, stock_turnover_180d, stock_turnover_365d, stock_turnover_total,
avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try, avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try,
gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d, gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d,
unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d, market_count_90d, customer_count_90d, sales_index_90d, unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d,
market_count_90d, market_count_180d, market_count_365d, market_count_total,
customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total,
sales_index_90d, sales_index_180d, sales_index_365d, sales_index_total,
price_index_90d, margin_index_90d, performance_score, performance_bucket, price_index_90d, margin_index_90d, performance_score, performance_bucket,
recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number, recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number,
to_char(updated_at,'YYYY-MM-DD HH24:MI:SS') to_char(updated_at,'YYYY-MM-DD HH24:MI:SS')
@@ -1801,11 +1826,15 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
&r.SalesQty30, &r.SalesQty90, &r.SalesQty180, &r.SalesQty365, &r.SalesQty730, &r.SalesQtyTotal, &r.SalesQty30, &r.SalesQty90, &r.SalesQty180, &r.SalesQty365, &r.SalesQty730, &r.SalesQtyTotal,
&r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.SalesUSDTotal, &r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.SalesUSDTotal,
&r.AvgDailySales90, &r.AvgDailySales180, &r.AvgDailySales365, &r.AvgDailySalesTotal, &r.AvgDailySales90, &r.AvgDailySales180, &r.AvgDailySales365, &r.AvgDailySalesTotal,
&r.AvgStock90, &r.AvgStock180, &r.AvgStock365, &r.AvgStockTotal,
&r.StockDays90, &r.StockDays180, &r.StockDays365, &r.StockDaysTotal, &r.StockDays90, &r.StockDays180, &r.StockDays365, &r.StockDaysTotal,
&r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal,
&r.AvgPriceUSD90, &r.AvgPriceUSD180, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY, &r.AvgPriceUSD90, &r.AvgPriceUSD180, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY,
&r.GrossProfitUSD90, &r.GrossProfitUSD180, &r.GrossMargin90, &r.GrossMargin180, &r.GrossProfitUSD90, &r.GrossProfitUSD180, &r.GrossMargin90, &r.GrossMargin180,
&r.UnitProfitCost90, &r.UnitProfitCost180, &r.UnitProfitBase90, &r.UnitProfitBase180, &r.MarketCount90, &r.CustomerCount90, &r.SalesIndex90, &r.UnitProfitCost90, &r.UnitProfitCost180, &r.UnitProfitBase90, &r.UnitProfitBase180,
&r.MarketCount90, &r.MarketCount180, &r.MarketCount365, &r.MarketCountTotal,
&r.CustomerCount90, &r.CustomerCount180, &r.CustomerCount365, &r.CustomerCountTotal,
&r.SalesIndex90, &r.SalesIndex180, &r.SalesIndex365, &r.SalesIndexTotal,
&r.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket, &r.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket,
&r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt, &r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt,
); err != nil { ); err != nil {
@@ -1829,37 +1858,109 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
func applyProductPerformanceProductRowScores(rows []models.ProductPerformanceRow) { func applyProductPerformanceProductRowScores(rows []models.ProductPerformanceRow) {
avg := productPerformanceProductRowAverages(rows) avg := productPerformanceProductRowAverages(rows)
for i := range rows { for i := range rows {
rows[i].PerformanceScore = productPerformanceProductScore( rows[i].SalesIndex90 = productPerformanceRelativeIndex(rows[i].SalesUSD90, avg.salesUSD90)
rows[i].SalesIndex180 = productPerformanceRelativeIndex(rows[i].SalesUSD180, avg.salesUSD180)
rows[i].SalesIndex365 = productPerformanceRelativeIndex(rows[i].SalesUSD365, avg.salesUSD365)
rows[i].SalesIndexTotal = productPerformanceRelativeIndex(rows[i].SalesUSDTotal, avg.salesUSDTotal)
margin365 := productPerformanceMarginFromSales(rows[i].SalesUSD365, rows[i].SalesQty365, rows[i].CostPriceUSD)
marginTotal := productPerformanceMarginFromSales(rows[i].SalesUSDTotal, rows[i].SalesQtyTotal, rows[i].CostPriceUSD)
rows[i].PerformanceScore90 = productPerformanceProductScore(
"90d", "90d",
rows[i].SalesUSD90, rows[i].SalesUSD90,
productPerformanceRelativeIndex(rows[i].SalesUSD90, avg.salesUSD90), rows[i].SalesIndex90,
rows[i].GrossMargin90, rows[i].GrossMargin90,
rows[i].StockTurnover90, rows[i].StockTurnover90,
float64(rows[i].MarketCount90), float64(rows[i].MarketCount90),
float64(rows[i].CustomerCount90), float64(rows[i].CustomerCount90),
) )
rows[i].PerformanceScore180 = productPerformanceProductScore(
"180d",
rows[i].SalesUSD180,
rows[i].SalesIndex180,
rows[i].GrossMargin180,
rows[i].StockTurnover180,
float64(rows[i].MarketCount180),
float64(rows[i].CustomerCount180),
)
rows[i].PerformanceScore365 = productPerformanceProductScore(
"365d",
rows[i].SalesUSD365,
rows[i].SalesIndex365,
margin365,
rows[i].StockTurnover365,
float64(rows[i].MarketCount365),
float64(rows[i].CustomerCount365),
)
rows[i].PerformanceScoreTotal = productPerformanceProductScore(
"total",
rows[i].SalesUSDTotal,
rows[i].SalesIndexTotal,
marginTotal,
rows[i].StockTurnoverTotal,
float64(rows[i].MarketCountTotal),
float64(rows[i].CustomerCountTotal),
productPerformanceProductRowTotalPeriodDays(rows[i]),
)
rows[i].PerformanceScore = rows[i].PerformanceScore90
} }
} }
type productPerformanceProductRowAverage struct { type productPerformanceProductRowAverage struct {
salesUSD90 float64 salesUSD90 float64
salesUSD180 float64
salesUSD365 float64
salesUSDTotal float64
} }
func productPerformanceProductRowAverages(rows []models.ProductPerformanceRow) productPerformanceProductRowAverage { func productPerformanceProductRowAverages(rows []models.ProductPerformanceRow) productPerformanceProductRowAverage {
var sum90, count90 float64 var sum90, count90, sum180, count180, sum365, count365, sumTotal, countTotal float64
for _, row := range rows { for _, row := range rows {
if row.SalesUSD90 > 0 { if row.SalesUSD90 > 0 {
sum90 += row.SalesUSD90 sum90 += row.SalesUSD90
count90++ count90++
} }
if row.SalesUSD180 > 0 {
sum180 += row.SalesUSD180
count180++
}
if row.SalesUSD365 > 0 {
sum365 += row.SalesUSD365
count365++
}
if row.SalesUSDTotal > 0 {
sumTotal += row.SalesUSDTotal
countTotal++
}
} }
out := productPerformanceProductRowAverage{} out := productPerformanceProductRowAverage{}
if count90 > 0 { if count90 > 0 {
out.salesUSD90 = sum90 / count90 out.salesUSD90 = sum90 / count90
} }
if count180 > 0 {
out.salesUSD180 = sum180 / count180
}
if count365 > 0 {
out.salesUSD365 = sum365 / count365
}
if countTotal > 0 {
out.salesUSDTotal = sumTotal / countTotal
}
return out return out
} }
func productPerformanceProductRowTotalPeriodDays(row models.ProductPerformanceRow) float64 {
return productPerformancePeriodDays(map[string]any{"kpi_date": row.KpiDate}, "total")
}
func productPerformanceMarginFromSales(salesUSD, salesQty, unitCost float64) float64 {
if salesUSD <= 0 {
return 0
}
return (salesUSD - (salesQty * unitCost)) / salesUSD
}
func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) { func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) {
if err := EnsureProductPerformanceTables(pg); err != nil { if err := EnsureProductPerformanceTables(pg); err != nil {
return models.ProductPerformanceSummary{}, err return models.ProductPerformanceSummary{}, err
@@ -2190,7 +2291,7 @@ SELECT
WHEN sales_qty_total > 0 AND stock_qty <= 0 AND sales_index_total >= 1 THEN 'STOKSUZ_TALEP' WHEN sales_qty_total > 0 AND stock_qty <= 0 AND sales_index_total >= 1 THEN 'STOKSUZ_TALEP'
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'STOK_RISKI' WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'STOK_RISKI'
WHEN gross_margin_total < 0 THEN 'FIYAT_BASKISI' WHEN gross_margin_total < 0 THEN 'FIYAT_BASKISI'
WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.25 THEN 'YILDIZ_URUN' WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.45 THEN 'YILDIZ_URUN'
WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'FIYAT_FIRSATI' WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'FIYAT_FIRSATI'
ELSE 'TAKIP' ELSE 'TAKIP'
END AS performance_bucket, END AS performance_bucket,
@@ -2198,7 +2299,7 @@ SELECT
WHEN sales_qty_total > 0 AND stock_qty <= 0 THEN 'Talep var, stok yok. Uretim/satin alma onceligi ver.' WHEN sales_qty_total > 0 AND stock_qty <= 0 THEN 'Talep var, stok yok. Uretim/satin alma onceligi ver.'
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'Satis yok, stok maliyeti tasiyor. Piyasa/fiyat aksiyonu gerekli.' WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'Satis yok, stok maliyeti tasiyor. Piyasa/fiyat aksiyonu gerekli.'
WHEN gross_margin_total < 0 THEN 'Ciplak maliyet altinda satis var. Fiyat veya maliyet kontrol edilmeli.' WHEN gross_margin_total < 0 THEN 'Ciplak maliyet altinda satis var. Fiyat veya maliyet kontrol edilmeli.'
WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.25 THEN 'Genel donemde guclu urun. Stok ve fiyat korunmali.' WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.45 THEN 'Genel donemde guclu urun. Stok ve fiyat korunmali.'
WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'Karli ama yavas. Dogru piyasada satis firsati var.' WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'Karli ama yavas. Dogru piyasada satis firsati var.'
ELSE 'Izleme ve piyasa bazli aksiyon.' ELSE 'Izleme ve piyasa bazli aksiyon.'
END AS recommendation, END AS recommendation,
@@ -3485,6 +3586,19 @@ StockTotalStart AS (
WHERE stock_date <= DATE '2022-01-01' WHERE stock_date <= DATE '2022-01-01'
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
), ),
FirstSaleByProduct AS (
SELECT
product_code,
color_code,
yaka_kodu,
MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days') AS first_sale_date_90d,
MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days') AS first_sale_date_180d,
MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days') AS first_sale_date_365d,
MIN(sales_date) FILTER (WHERE sales_date >= DATE '2022-01-01') AS first_sale_date_total
FROM mk_product_performance_sales_daily
WHERE sales_date >= DATE '2022-01-01'
GROUP BY product_code, color_code, yaka_kodu
),
Agg AS ( Agg AS (
SELECT SELECT
$2::text AS breakdown, $2::text AS breakdown,
@@ -3587,18 +3701,55 @@ Agg AS (
COALESCE(pd.base_price_usd,0) AS base_price_usd, COALESCE(pd.base_price_usd,0) AS base_price_usd,
COALESCE(pd.cost_price_usd,0) AS cost_price_usd, COALESCE(pd.cost_price_usd,0) AS cost_price_usd,
COALESCE(st.stock_qty,0) AS stock_qty, COALESCE(st.stock_qty,0) AS stock_qty,
(COALESCE(s90.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_90d, (COALESCE(s90.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_90d,
(COALESCE(s180.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_180d, (COALESCE(s180.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_180d,
(COALESCE(s365.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_365d, (COALESCE(s365.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_365d,
(COALESCE(stotal.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_total (COALESCE(stotal.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_total
FROM mk_product_performance_sales_daily s FROM mk_product_performance_sales_daily s
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code
LEFT JOIN LatestKPI k ON k.product_code = s.product_code AND k.color_code = s.color_code AND k.yaka_kodu = s.yaka_kodu LEFT JOIN LatestKPI k ON k.product_code = s.product_code AND k.color_code = s.color_code AND k.yaka_kodu = s.yaka_kodu
LEFT JOIN FirstSaleByProduct fs ON fs.product_code = s.product_code AND fs.color_code = s.color_code AND fs.yaka_kodu = s.yaka_kodu
LEFT JOIN StockAgg st ON st.product_code = s.product_code AND st.color_code = s.color_code AND st.yaka_kodu = s.yaka_kodu LEFT JOIN StockAgg st ON st.product_code = s.product_code AND st.color_code = s.color_code AND st.yaka_kodu = s.yaka_kodu
LEFT JOIN Stock90Start s90 ON s90.product_code = s.product_code AND s90.color_code = s.color_code AND s90.yaka_kodu = s.yaka_kodu LEFT JOIN LATERAL (
LEFT JOIN Stock180Start s180 ON s180.product_code = s.product_code AND s180.color_code = s.color_code AND s180.yaka_kodu = s.yaka_kodu SELECT x.stock_qty
LEFT JOIN Stock365Start s365 ON s365.product_code = s.product_code AND s365.color_code = s.color_code AND s365.yaka_kodu = s.yaka_kodu FROM mk_product_performance_stock_daily x
LEFT JOIN StockTotalStart stotal ON stotal.product_code = s.product_code AND stotal.color_code = s.color_code AND stotal.yaka_kodu = s.yaka_kodu WHERE x.product_code = s.product_code
AND x.color_code = s.color_code
AND x.yaka_kodu = s.yaka_kodu
AND x.stock_date <= COALESCE(fs.first_sale_date_90d, current_date - INTERVAL '89 days')
ORDER BY x.stock_date DESC
LIMIT 1
) s90 ON TRUE
LEFT JOIN LATERAL (
SELECT x.stock_qty
FROM mk_product_performance_stock_daily x
WHERE x.product_code = s.product_code
AND x.color_code = s.color_code
AND x.yaka_kodu = s.yaka_kodu
AND x.stock_date <= COALESCE(fs.first_sale_date_180d, current_date - INTERVAL '179 days')
ORDER BY x.stock_date DESC
LIMIT 1
) s180 ON TRUE
LEFT JOIN LATERAL (
SELECT x.stock_qty
FROM mk_product_performance_stock_daily x
WHERE x.product_code = s.product_code
AND x.color_code = s.color_code
AND x.yaka_kodu = s.yaka_kodu
AND x.stock_date <= COALESCE(fs.first_sale_date_365d, current_date - INTERVAL '359 days')
ORDER BY x.stock_date DESC
LIMIT 1
) s365 ON TRUE
LEFT JOIN LATERAL (
SELECT x.stock_qty
FROM mk_product_performance_stock_daily x
WHERE x.product_code = s.product_code
AND x.color_code = s.color_code
AND x.yaka_kodu = s.yaka_kodu
AND x.stock_date <= COALESCE(fs.first_sale_date_total, DATE '2022-01-01')
ORDER BY x.stock_date DESC
LIMIT 1
) stotal ON TRUE
) s ) s
WHERE sales_date >= DATE '2022-01-01' WHERE sales_date >= DATE '2022-01-01'
AND upper(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') 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')
@@ -3704,16 +3855,20 @@ SELECT
gross_profit_cost_usd_total, gross_profit_cost_usd_total,
gross_margin_base_total, gross_margin_base_total,
gross_margin_cost_total, gross_margin_cost_total,
CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END AS stock_turnover_90d, avg_stock_90d,
CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) ELSE 0 END AS stock_turnover_180d, avg_stock_180d,
avg_stock_365d,
avg_stock_total,
CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END AS stock_turnover_90d,
CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END AS stock_turnover_180d,
CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END AS stock_turnover_365d, CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END AS stock_turnover_365d,
CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) ELSE 0 END AS stock_turnover_total, CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, (current_date - DATE '2022-01-01') + 1) ELSE 0 END AS stock_turnover_total,
has_cost, has_cost,
sales_index_90d, sales_index_90d,
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE
ROUND(( ROUND((
LEAST(35, sales_usd_90d / 25000 * 35) LEAST(35, sales_usd_90d / 25000 * 35)
+ LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.40 * 30) + LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.45 * 30)
+ LEAST(20, product_group_count_90d / 8.0 * 20) + LEAST(20, product_group_count_90d / 8.0 * 20)
+ LEAST(15, sales_qty_90d / 500 * 15) + LEAST(15, sales_qty_90d / 500 * 15)
)::numeric, 4) )::numeric, 4)
@@ -3721,7 +3876,7 @@ SELECT
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_180d <= 0 THEN 1 ELSE CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_180d <= 0 THEN 1 ELSE
ROUND(( ROUND((
LEAST(35, sales_usd_180d / 50000 * 35) LEAST(35, sales_usd_180d / 50000 * 35)
+ LEAST(30, GREATEST(gross_margin_cost_180d,0) / 0.40 * 30) + LEAST(30, GREATEST(gross_margin_cost_180d,0) / 0.45 * 30)
+ LEAST(20, product_group_count_180d / 8.0 * 20) + LEAST(20, product_group_count_180d / 8.0 * 20)
+ LEAST(15, sales_qty_180d / 1000 * 15) + LEAST(15, sales_qty_180d / 1000 * 15)
)::numeric, 4) )::numeric, 4)
@@ -3729,7 +3884,7 @@ SELECT
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_365d <= 0 THEN 1 ELSE CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_365d <= 0 THEN 1 ELSE
ROUND(( ROUND((
LEAST(35, sales_usd_365d / 100000 * 35) LEAST(35, sales_usd_365d / 100000 * 35)
+ LEAST(30, GREATEST(gross_margin_cost_365d,0) / 0.40 * 30) + LEAST(30, GREATEST(gross_margin_cost_365d,0) / 0.45 * 30)
+ LEAST(20, product_group_count_365d / 8.0 * 20) + LEAST(20, product_group_count_365d / 8.0 * 20)
+ LEAST(15, sales_qty_365d / 2000 * 15) + LEAST(15, sales_qty_365d / 2000 * 15)
)::numeric, 4) )::numeric, 4)
@@ -3737,18 +3892,18 @@ SELECT
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_total <= 0 THEN 1 ELSE CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_total <= 0 THEN 1 ELSE
ROUND(( ROUND((
LEAST(35, sales_usd_total / 300000 * 35) LEAST(35, sales_usd_total / 300000 * 35)
+ LEAST(30, GREATEST(gross_margin_cost_total,0) / 0.40 * 30) + LEAST(30, GREATEST(gross_margin_cost_total,0) / 0.45 * 30)
+ LEAST(20, product_group_count_total / 8.0 * 20) + LEAST(20, product_group_count_total / 8.0 * 20)
+ LEAST(15, sales_qty_total / 6000 * 15) + LEAST(15, sales_qty_total / 6000 * 15)
)::numeric, 4) )::numeric, 4)
END AS customer_score_total, END AS customer_score_total,
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE
ROUND( ROUND(
LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.40 * 30) LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.45 * 30)
+ LEAST(20, (CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END) / 1.5 * 20) + LEAST(20, (CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END) / 4.0 * 20)
+ LEAST(20, sales_usd_90d / 10000 * 20) + LEAST(20, sales_usd_90d / 25000 * 20)
+ LEAST(15, market_count_90d / 8.0 * 15) + LEAST(5, market_count_90d / 3.0 * 5)
+ LEAST(15, customer_count_90d / 25.0 * 15), + LEAST(25, customer_count_90d / 8.0 * 25),
4 4
) )
END AS performance_score, END AS performance_score,
@@ -3812,6 +3967,7 @@ LIMIT $1
&r.GrossMarginCost365, &r.CustomerCount365, &r.InvoiceCount365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal, &r.GrossMarginCost365, &r.CustomerCount365, &r.InvoiceCount365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal,
&r.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal, &r.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal,
&r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal, &r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal,
&r.AvgStock90, &r.AvgStock180, &r.AvgStock365, &r.AvgStockTotal,
&r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.HasCost, &r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.HasCost,
&r.SalesIndex90, &r.CustomerScore90, &r.CustomerScore180, &r.CustomerScore365, &r.CustomerScoreTotal, &r.PerformanceScore, &r.SalesIndex90, &r.CustomerScore90, &r.CustomerScore180, &r.CustomerScore365, &r.CustomerScoreTotal, &r.PerformanceScore,
&r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate, &r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate,
@@ -3920,6 +4076,7 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
sourceRows = filterProductPerformanceGroupedRows(sourceRows, productPerformanceGroupedEffectiveFilters(req)) sourceRows = filterProductPerformanceGroupedRows(sourceRows, productPerformanceGroupedEffectiveFilters(req))
out := make([]map[string]any, 0, len(sourceRows)) out := make([]map[string]any, 0, len(sourceRows))
appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys, req.ExpandThroughLevel) appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys, req.ExpandThroughLevel)
out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending)
if len(out) > 0 || !productPerformanceLiveFallbackEnabled() { if len(out) > 0 || !productPerformanceLiveFallbackEnabled() {
return out, nil return out, nil
} }
@@ -4038,6 +4195,9 @@ func loadProductPerformancePreparedGroupedRows(ctx context.Context, pg *sql.DB,
} }
effectiveFilters := productPerformancePreparedGroupedEffectiveFilters(req) effectiveFilters := productPerformancePreparedGroupedEffectiveFilters(req)
hasFilters := len(effectiveFilters) > 0 hasFilters := len(effectiveFilters) > 0
if hasFilters {
return nil, false, nil
}
hasManualExpansion := len(req.ExpandedKeys) > 0 hasManualExpansion := len(req.ExpandedKeys) > 0
query := ` query := `
SELECT payload SELECT payload
@@ -4098,7 +4258,6 @@ SELECT EXISTS (
return nil, false, nil return nil, false, nil
} }
} }
out = filterProductPerformancePreparedGroupedRows(out, effectiveFilters)
out = filterProductPerformanceGroupedRowsForExpansion(out, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit) out = filterProductPerformanceGroupedRowsForExpansion(out, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit)
out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending) out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending)
return out, true, nil return out, true, nil
@@ -4257,8 +4416,12 @@ func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req Produ
out := make([]map[string]any, 0, 512) out := make([]map[string]any, 0, 512)
filters := productPerformanceGroupedBaseFilters(req) filters := productPerformanceGroupedBaseFilters(req)
err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, 0, []string{"tab:" + mode}, filters, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit) err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, 0, []string{"tab:" + mode}, filters, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit)
if err != nil {
return out, true, err return out, true, err
} }
out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending)
return out, true, nil
}
func productPerformanceGroupedBaseFilters(req ProductPerformanceGroupedRequest) []productPerformanceSQLGroupFilter { func productPerformanceGroupedBaseFilters(req ProductPerformanceGroupedRequest) []productPerformanceSQLGroupFilter {
effectiveFilters := productPerformanceGroupedEffectiveFilters(req) effectiveFilters := productPerformanceGroupedEffectiveFilters(req)
@@ -4335,6 +4498,7 @@ func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out
row["label"] = value row["label"] = value
row["__group"] = true row["__group"] = true
row[field] = value row[field] = value
deriveProductPerformanceGroupMetrics(row, field)
*out = append(*out, row) *out = append(*out, row)
if expandedKeys[key] || level <= expandThroughLevel { if expandedKeys[key] || level <= expandThroughLevel {
nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value}) nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value})
@@ -4360,6 +4524,8 @@ SELECT (
jsonb_build_object( jsonb_build_object(
'group_value', group_value, 'group_value', group_value,
'label', group_value, 'label', group_value,
'period_start', '2022-01-01',
'period_end', COALESCE(to_char((SELECT kpi_date FROM LatestKPIDate),'YYYY-MM-DD'), ''),
'count', row_count, 'count', row_count,
'recommendation', CASE WHEN recommendation <> '' THEN recommendation ELSE row_count::text || ' satir' END, 'recommendation', CASE WHEN recommendation <> '' THEN recommendation ELSE row_count::text || ' satir' END,
'image_product_code', image_product_code, 'image_product_code', image_product_code,
@@ -4378,6 +4544,10 @@ SELECT (
) )
|| jsonb_build_object( || jsonb_build_object(
'stock_qty', stock_qty, 'stock_qty', stock_qty,
'avg_stock_90d', avg_stock_90d,
'avg_stock_180d', avg_stock_180d,
'avg_stock_365d', avg_stock_365d,
'avg_stock_total', avg_stock_total,
'sales_qty_90d', sales_qty_90d, 'sales_qty_90d', sales_qty_90d,
'sales_qty_180d', sales_qty_180d, 'sales_qty_180d', sales_qty_180d,
'sales_qty_365d', sales_qty_365d, 'sales_qty_365d', sales_qty_365d,
@@ -4402,8 +4572,17 @@ SELECT (
'unit_profit_base_90d', unit_profit_base_90d, 'unit_profit_base_90d', unit_profit_base_90d,
'unit_profit_base_180d', unit_profit_base_180d, 'unit_profit_base_180d', unit_profit_base_180d,
'market_count_90d', market_count_90d, 'market_count_90d', market_count_90d,
'market_count_180d', market_count_180d,
'market_count_365d', market_count_365d,
'market_count_total', market_count_total,
'customer_count_90d', customer_count_90d, 'customer_count_90d', customer_count_90d,
'customer_count_180d', customer_count_180d,
'customer_count_365d', customer_count_365d,
'customer_count_total', customer_count_total,
'sales_index_90d', sales_index_90d, 'sales_index_90d', sales_index_90d,
'sales_index_180d', sales_index_180d,
'sales_index_365d', sales_index_365d,
'sales_index_total', sales_index_total,
'price_index_90d', price_index_90d, 'price_index_90d', price_index_90d,
'margin_index_90d', margin_index_90d, 'margin_index_90d', margin_index_90d,
'performance_score', performance_score, 'performance_score', performance_score,
@@ -4413,10 +4592,10 @@ SELECT (
'stock_days_180d', stock_days_180d, 'stock_days_180d', stock_days_180d,
'stock_days_365d', stock_days_365d, 'stock_days_365d', stock_days_365d,
'stock_days_total', stock_days_total, 'stock_days_total', stock_days_total,
'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END, 'stock_turnover_90d', stock_turnover_90d,
'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) ELSE 0 END, 'stock_turnover_180d', stock_turnover_180d,
'stock_turnover_365d', CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END, 'stock_turnover_365d', stock_turnover_365d,
'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) ELSE 0 END 'stock_turnover_total', stock_turnover_total
) )
) AS row_json ) AS row_json
FROM ( FROM (
@@ -4481,33 +4660,37 @@ FROM (
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_cost_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_cost_180d, CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_cost_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_cost_180d,
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_base_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS unit_profit_base_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_base_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS unit_profit_base_90d,
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_base_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_base_180d, CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_base_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_base_180d,
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_90d,0) > 0)::integer AS market_count_90d, COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_90d,0) > 0)::integer AS market_count_90d,
COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_180d,0) > 0)::integer AS market_count_180d,
COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_365d,0) > 0)::integer AS market_count_365d,
COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_total,0) > 0)::integer AS market_count_total,
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_90d,0) > 0 THEN customer_count_90d ELSE 0 END),0)::integer AS customer_count_90d, COALESCE(SUM(CASE WHEN COALESCE(sales_usd_90d,0) > 0 THEN customer_count_90d ELSE 0 END),0)::integer AS customer_count_90d,
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_180d,0) > 0 THEN customer_count_180d ELSE 0 END),0)::integer AS customer_count_180d,
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_365d,0) > 0 THEN customer_count_365d ELSE 0 END),0)::integer AS customer_count_365d,
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_total,0) > 0 THEN customer_count_total ELSE 0 END),0)::integer AS customer_count_total,
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS sales_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS sales_index_90d,
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_index_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS sales_index_180d,
CASE WHEN SUM(sales_qty_365d) > 0 THEN SUM(sales_index_365d * sales_qty_365d) / NULLIF(SUM(sales_qty_365d),0) ELSE 0 END AS sales_index_365d,
CASE WHEN SUM(sales_qty_total) > 0 THEN SUM(sales_index_total * sales_qty_total) / NULLIF(SUM(sales_qty_total),0) ELSE 0 END AS sales_index_total,
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS price_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS price_index_90d,
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS margin_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS margin_index_90d,
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(stock_turnover_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS stock_turnover_90d,
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(stock_turnover_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS stock_turnover_180d,
CASE WHEN SUM(sales_qty_365d) > 0 THEN SUM(stock_turnover_365d * sales_qty_365d) / NULLIF(SUM(sales_qty_365d),0) ELSE 0 END AS stock_turnover_365d,
CASE WHEN SUM(sales_qty_total) > 0 THEN SUM(stock_turnover_total * sales_qty_total) / NULLIF(SUM(sales_qty_total),0) ELSE 0 END AS stock_turnover_total,
CASE CASE
WHEN SUM(CASE WHEN SUM(CASE
WHEN COALESCE(sales_usd_90d,0) > 0 THEN sales_usd_90d
WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d
WHEN COALESCE(stock_qty,0) > 0 AND COALESCE(cost_price_usd,0) > 0 THEN stock_qty * cost_price_usd ELSE 0
WHEN COALESCE(stock_qty,0) > 0 THEN stock_qty
ELSE 1
END) > 0 END) > 0
THEN SUM(performance_score * CASE THEN SUM(performance_score * CASE
WHEN COALESCE(sales_usd_90d,0) > 0 THEN sales_usd_90d
WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d
WHEN COALESCE(stock_qty,0) > 0 AND COALESCE(cost_price_usd,0) > 0 THEN stock_qty * cost_price_usd
WHEN COALESCE(stock_qty,0) > 0 THEN stock_qty
ELSE 1
END) / NULLIF(SUM(CASE
WHEN COALESCE(sales_usd_90d,0) > 0 THEN sales_usd_90d
WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d
WHEN COALESCE(stock_qty,0) > 0 AND COALESCE(cost_price_usd,0) > 0 THEN stock_qty * cost_price_usd
WHEN COALESCE(stock_qty,0) > 0 THEN stock_qty
ELSE 1
END),0)
ELSE 0 ELSE 0
END) / NULLIF(SUM(CASE
WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d
ELSE 0
END),0)
ELSE COALESCE(AVG(performance_score),0)
END AS performance_score, END AS performance_score,
MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket, MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket,
COALESCE(MODE() WITHIN GROUP (ORDER BY NULLIF(recommendation,'')), '') AS recommendation, COALESCE(MODE() WITHIN GROUP (ORDER BY NULLIF(recommendation,'')), '') AS recommendation,
@@ -4515,6 +4698,11 @@ FROM (
FROM ( FROM (
SELECT SELECT
Source.*, Source.*,
btrim(regexp_replace(COALESCE(Source.market_key,''), '^.*[|]', '')) AS display_market_key,
CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END AS stock_turnover_90d,
CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END AS stock_turnover_180d,
CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END AS stock_turnover_365d,
CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, ((SELECT kpi_date FROM LatestKPIDate) - DATE '2022-01-01') + 1) ELSE 0 END AS stock_turnover_total,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY COALESCE(%s, ''), product_code, color_code, yaka_kodu PARTITION BY COALESCE(%s, ''), product_code, color_code, yaka_kodu
ORDER BY performance_score DESC NULLS LAST ORDER BY performance_score DESC NULLS LAST
@@ -4539,10 +4727,10 @@ func queryProductPerformanceSQLLeafRows(ctx context.Context, pg *sql.DB, mode st
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(` query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
SELECT to_jsonb(t) || jsonb_build_object( SELECT to_jsonb(t) || jsonb_build_object(
'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key, 'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key,
'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END, 'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END,
'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) ELSE 0 END, 'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END,
'stock_turnover_365d', CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END, 'stock_turnover_365d', CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END,
'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) ELSE 0 END 'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, ((SELECT kpi_date FROM LatestKPIDate) - DATE '2022-01-01') + 1) ELSE 0 END
) AS row_json ) AS row_json
FROM Source t FROM Source t
%s %s
@@ -4641,10 +4829,10 @@ Source AS (
urun_alt_grubu, urun_alt_grubu,
market_key, market_key,
COALESCE(stock_qty,0) AS stock_qty, COALESCE(stock_qty,0) AS stock_qty,
(COALESCE((SELECT s.stock_qty FROM Stock90Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0 AS avg_stock_90d, COALESCE(NULLIF(avg_stock_90d,0), (COALESCE((SELECT s.stock_qty FROM Stock90Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_90d,
(COALESCE((SELECT s.stock_qty FROM Stock180Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0 AS avg_stock_180d, COALESCE(NULLIF(avg_stock_180d,0), (COALESCE((SELECT s.stock_qty FROM Stock180Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_180d,
(COALESCE((SELECT s.stock_qty FROM Stock365Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0 AS avg_stock_365d, COALESCE(NULLIF(avg_stock_365d,0), (COALESCE((SELECT s.stock_qty FROM Stock365Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_365d,
(COALESCE((SELECT s.stock_qty FROM StockTotalStart s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0 AS avg_stock_total, COALESCE(NULLIF(avg_stock_total,0), (COALESCE((SELECT s.stock_qty FROM StockTotalStart s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_total,
COALESCE(sales_qty_90d,0) AS sales_qty_90d, COALESCE(sales_qty_90d,0) AS sales_qty_90d,
COALESCE(sales_qty_180d,0) AS sales_qty_180d, COALESCE(sales_qty_180d,0) AS sales_qty_180d,
COALESCE(sales_qty_365d,0) AS sales_qty_365d, COALESCE(sales_qty_365d,0) AS sales_qty_365d,
@@ -4671,8 +4859,17 @@ Source AS (
COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d, COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d,
COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d, COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d,
COALESCE(market_count_90d,0) AS market_count_90d, COALESCE(market_count_90d,0) AS market_count_90d,
COALESCE(market_count_180d,0) AS market_count_180d,
COALESCE(market_count_365d,0) AS market_count_365d,
COALESCE(market_count_total,0) AS market_count_total,
COALESCE(customer_count_90d,0) AS customer_count_90d, COALESCE(customer_count_90d,0) AS customer_count_90d,
COALESCE(customer_count_180d,0) AS customer_count_180d,
COALESCE(customer_count_365d,0) AS customer_count_365d,
COALESCE(customer_count_total,0) AS customer_count_total,
COALESCE(sales_index_90d,0) AS sales_index_90d, COALESCE(sales_index_90d,0) AS sales_index_90d,
COALESCE(sales_index_180d,0) AS sales_index_180d,
COALESCE(sales_index_365d,0) AS sales_index_365d,
COALESCE(sales_index_total,0) AS sales_index_total,
COALESCE(price_index_90d,0) AS price_index_90d, COALESCE(price_index_90d,0) AS price_index_90d,
COALESCE(margin_index_90d,0) AS margin_index_90d, COALESCE(margin_index_90d,0) AS margin_index_90d,
COALESCE(performance_score,0) AS performance_score, COALESCE(performance_score,0) AS performance_score,
@@ -4698,7 +4895,7 @@ func productPerformanceSQLGroupExpr(field string) (string, bool) {
case "color_yaka": case "color_yaka":
return "concat_ws('/', NULLIF(btrim(COALESCE(color_code,'')), ''), NULLIF(btrim(COALESCE(yaka_kodu,'')), ''))", true return "concat_ws('/', NULLIF(btrim(COALESCE(color_code,'')), ''), NULLIF(btrim(COALESCE(yaka_kodu,'')), ''))", true
case "market_key": case "market_key":
return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true return "btrim(regexp_replace(COALESCE(market_key,''), '^.*[|]', ''))", true
default: default:
return "", false return "", false
} }
@@ -4988,9 +5185,9 @@ func productPerformanceGroupedFilterValue(row map[string]any, field string) stri
} }
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) { func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
if rows, ok, err := productPerformanceGroupedSnapshotRows(ctx, pg, mode, limit); err != nil { if rows, err := productPerformanceGroupedRawSnapshotSourceRows(ctx, pg, mode, limit); err != nil {
return nil, err return nil, err
} else if ok { } else if rows != nil {
return rows, nil return rows, nil
} }
@@ -5032,14 +5229,30 @@ func productPerformanceGroupedRawSnapshotSourceRows(ctx context.Context, pg *sql
return nil, nil return nil, nil
} }
if strings.TrimSpace(mode) == "idle" { if strings.TrimSpace(mode) == "idle" {
normalizeProductPerformanceGroupedSourceScores(rows, mode)
return productPerformanceIdleSourceRows(rows), nil return productPerformanceIdleSourceRows(rows), nil
} }
if mode == "products" || mode == "product_detail" { if mode == "products" || mode == "product_detail" {
rows = mergeProductPerformanceGeneralSnapshotMetrics(ctx, pg, rows) rows = mergeProductPerformanceGeneralSnapshotMetrics(ctx, pg, rows)
rows = mergeProductPerformanceSalesSpreadKeys(ctx, pg, rows)
} }
normalizeProductPerformanceGroupedSourceScores(rows, mode)
return rows, nil return rows, nil
} }
func normalizeProductPerformanceGroupedSourceScores(rows []map[string]any, mode string) {
if !shouldCascadeProductPerformanceGroupedScores(mode) {
return
}
for _, row := range rows {
deriveProductPerformanceGroupMetrics(row, "")
for _, suffix := range productPerformancePeriodSuffixes() {
row["performance_score_"+suffix] = productPerformanceSalesPeriodScore(row, suffix)
}
row["performance_score"] = row["performance_score_90d"]
}
}
func mergeProductPerformanceGeneralSnapshotMetrics(ctx context.Context, pg *sql.DB, rows []map[string]any) []map[string]any { func mergeProductPerformanceGeneralSnapshotMetrics(ctx context.Context, pg *sql.DB, rows []map[string]any) []map[string]any {
if len(rows) == 0 { if len(rows) == 0 {
return rows return rows
@@ -5070,9 +5283,124 @@ func mergeProductPerformanceGeneralSnapshotMetrics(ctx context.Context, pg *sql.
} }
} }
if value, ok := general["performance_score"]; ok { if value, ok := general["performance_score"]; ok {
if _, exists := row["performance_score_total"]; !exists {
row["performance_score_total"] = value row["performance_score_total"] = value
} }
} }
}
return rows
}
func mergeProductPerformanceSalesSpreadKeys(ctx context.Context, pg *sql.DB, rows []map[string]any) []map[string]any {
if len(rows) == 0 {
return rows
}
const query = `
WITH Latest AS (
SELECT COALESCE(
(SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily),
(SELECT MAX(sales_date) FROM mk_product_performance_sales_daily),
current_date
)::date AS kpi_date
),
Spread AS (
SELECT
s.product_code,
s.color_code,
s.yaka_kodu,
s.market_key,
COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER (
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '89 days' AND Latest.kpi_date
AND COALESCE(s.sales_usd,0) > 0
AND display_market_key NOT IN ('', 'STOK', '-')
), '[]'::jsonb) AS market_keys_90d,
COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER (
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '179 days' AND Latest.kpi_date
AND COALESCE(s.sales_usd,0) > 0
AND display_market_key NOT IN ('', 'STOK', '-')
), '[]'::jsonb) AS market_keys_180d,
COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER (
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '359 days' AND Latest.kpi_date
AND COALESCE(s.sales_usd,0) > 0
AND display_market_key NOT IN ('', 'STOK', '-')
), '[]'::jsonb) AS market_keys_365d,
COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER (
WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date
AND COALESCE(s.sales_usd,0) > 0
AND display_market_key NOT IN ('', 'STOK', '-')
), '[]'::jsonb) AS market_keys_total,
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '89 days' AND Latest.kpi_date
AND COALESCE(s.sales_usd,0) > 0
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
), '[]'::jsonb) AS customer_keys_90d,
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '179 days' AND Latest.kpi_date
AND COALESCE(s.sales_usd,0) > 0
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
), '[]'::jsonb) AS customer_keys_180d,
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '359 days' AND Latest.kpi_date
AND COALESCE(s.sales_usd,0) > 0
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
), '[]'::jsonb) AS customer_keys_365d,
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date
AND COALESCE(s.sales_usd,0) > 0
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
), '[]'::jsonb) AS customer_keys_total
FROM (
SELECT
s.*,
btrim(regexp_replace(COALESCE(s.market_key,''), '^.*[|]', '')) AS display_market_key
FROM mk_product_performance_sales_daily s
) s
CROSS JOIN Latest
WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date
AND upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
GROUP BY s.product_code, s.color_code, s.yaka_kodu, s.market_key
)
SELECT jsonb_build_object(
'product_code', product_code,
'color_code', color_code,
'yaka_kodu', yaka_kodu,
'market_key', market_key,
'__market_keys_90d', market_keys_90d,
'__market_keys_180d', market_keys_180d,
'__market_keys_365d', market_keys_365d,
'__market_keys_total', market_keys_total,
'__customer_keys_90d', customer_keys_90d,
'__customer_keys_180d', customer_keys_180d,
'__customer_keys_365d', customer_keys_365d,
'__customer_keys_total', customer_keys_total
)
FROM Spread`
spreadRows, err := queryProductPerformanceJSONRows(ctx, pg, query)
if err != nil {
log.Printf("[ProductPerformanceRefresh] product sales spread keys skipped err=%v", err)
return rows
}
byKey := make(map[string]map[string]any, len(spreadRows))
for _, row := range spreadRows {
key := productPerformanceMapMarketVariantKey(row)
if key != "" {
byKey[key] = row
}
}
for _, row := range rows {
spread := byKey[productPerformanceMapMarketVariantKey(row)]
if spread == nil {
continue
}
for _, suffix := range productPerformancePeriodSuffixes() {
for _, prefix := range []string{"__market_keys_", "__customer_keys_"} {
field := prefix + suffix
if value, ok := spread[field]; ok {
row[field] = value
}
}
}
}
return rows return rows
} }
@@ -5194,10 +5522,8 @@ type productPerformanceGroupedSnapshotNode struct {
Avg map[string]*productPerformanceGroupedSnapshotAvgState Avg map[string]*productPerformanceGroupedSnapshotAvgState
BucketCounts map[string]int BucketCounts map[string]int
Image map[string]any Image map[string]any
Market90Seen map[string]bool MarketSeen map[string]map[string]bool
MarketTotalSeen map[string]bool CustomerSeen map[string]map[string]bool
Customer90Seen map[string]bool
CustomerTotalSeen map[string]bool
} }
func buildProductPerformanceGroupedSnapshotRows(sourceRows []map[string]any, levels []string, mode string) []map[string]any { func buildProductPerformanceGroupedSnapshotRows(sourceRows []map[string]any, levels []string, mode string) []map[string]any {
@@ -5238,9 +5564,89 @@ func buildProductPerformanceGroupedSnapshotRows(sourceRows []map[string]any, lev
out := make([]map[string]any, 0, len(sourceRows)) out := make([]map[string]any, 0, len(sourceRows))
appendProductPerformanceGroupedSnapshotNodes(&out, roots, rootOrder) appendProductPerformanceGroupedSnapshotNodes(&out, roots, rootOrder)
applyProductPerformanceGroupedChildScoreAverages(out, mode)
return out return out
} }
func applyProductPerformanceGroupedChildScoreAverages(rows []map[string]any, mode string) {
if !shouldCascadeProductPerformanceGroupedScores(mode) || len(rows) == 0 {
return
}
byKey := make(map[string]map[string]any, len(rows))
children := map[string][]map[string]any{}
maxLevel := -1
for _, row := range rows {
key := stringFromMap(row, "key")
if key == "" {
continue
}
byKey[key] = row
if level := intFromMap(row, "level"); level > maxLevel {
maxLevel = level
}
}
for _, row := range rows {
key := stringFromMap(row, "key")
parentKey := productPerformanceParentGroupKey(key)
if parentKey == "" || byKey[parentKey] == nil {
continue
}
children[parentKey] = append(children[parentKey], row)
}
for level := maxLevel - 1; level >= 0; level-- {
for _, row := range rows {
if intFromMap(row, "level") != level {
continue
}
childRows := children[stringFromMap(row, "key")]
if len(childRows) == 0 {
continue
}
for _, suffix := range productPerformancePeriodSuffixes() {
field := "performance_score_" + suffix
if !productPerformanceRowsHaveField(childRows, field) {
continue
}
row[field] = weightedAverageProductPerformanceScoreRows(childRows, field, suffix)
}
for _, suffix := range productPerformancePeriodSuffixes() {
field := "stock_turnover_" + suffix
if !productPerformanceRowsHaveField(childRows, field) {
continue
}
row[field] = weightedAverageProductPerformanceRows(childRows, field, "sales_qty_"+suffix)
}
if score, ok := productPerformanceOptionalFloat(row, "performance_score_90d"); ok {
row["performance_score"] = score
}
}
}
}
func shouldCascadeProductPerformanceGroupedScores(mode string) bool {
switch strings.TrimSpace(mode) {
case "products",
"product_detail",
"idle",
"sales_color_yaka_market_customer",
"sales_product_country_segment_market_customer",
"sales_market_customer_product",
"sales_country_segment_market_customer_product":
return true
default:
return false
}
}
func productPerformanceRowsHaveField(rows []map[string]any, field string) bool {
for _, row := range rows {
if _, ok := productPerformanceOptionalFloat(row, field); ok {
return true
}
}
return false
}
func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) { func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) {
n.Count++ n.Count++
if n.Image == nil && stringFromMap(row, "product_code") != "" { if n.Image == nil && stringFromMap(row, "product_code") != "" {
@@ -5253,7 +5659,7 @@ func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) {
n.BucketCounts[bucket]++ n.BucketCounts[bucket]++
} }
for key, value := range row { for key, value := range row {
if key == "row_key" || key == "key" || isProductPerformanceMarginField(key) { if key == "row_key" || key == "key" || isProductPerformanceInternalGroupField(key) || isProductPerformanceMarginField(key) {
continue continue
} }
switch { switch {
@@ -5324,34 +5730,36 @@ func (n *productPerformanceGroupedSnapshotNode) addDistinctVariantMetric(row map
func (n *productPerformanceGroupedSnapshotNode) addDistinctSpread(row map[string]any) { func (n *productPerformanceGroupedSnapshotNode) addDistinctSpread(row map[string]any) {
market := displayProductPerformanceMarketName(stringFromMap(row, "market_key")) market := displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
if market != "" && market != "STOK" { if market != "" && market != "STOK" {
if floatFromMap(row, "sales_usd_90d") > 0 { for _, suffix := range productPerformancePeriodSuffixes() {
if n.Market90Seen == nil { if floatFromMap(row, "sales_usd_"+suffix) > 0 {
n.Market90Seen = map[string]bool{} if n.MarketSeen == nil {
n.MarketSeen = map[string]map[string]bool{}
} }
n.Market90Seen[market] = true if n.MarketSeen[suffix] == nil {
n.MarketSeen[suffix] = map[string]bool{}
} }
if floatFromMap(row, "sales_usd_total") > 0 { n.MarketSeen[suffix][market] = true
if n.MarketTotalSeen == nil {
n.MarketTotalSeen = map[string]bool{}
} }
n.MarketTotalSeen[market] = true
} }
} }
customer := strings.TrimSpace(stringFromMap(row, "customer_code")) customer := strings.TrimSpace(stringFromMap(row, "customer_code"))
if customer != "" && customer != "-" { if customer != "" && customer != "-" {
if floatFromMap(row, "sales_usd_90d") > 0 { for _, suffix := range productPerformancePeriodSuffixes() {
if n.Customer90Seen == nil { if floatFromMap(row, "sales_usd_"+suffix) > 0 {
n.Customer90Seen = map[string]bool{} if n.CustomerSeen == nil {
n.CustomerSeen = map[string]map[string]bool{}
} }
n.Customer90Seen[customer] = true if n.CustomerSeen[suffix] == nil {
n.CustomerSeen[suffix] = map[string]bool{}
} }
if floatFromMap(row, "sales_usd_total") > 0 { n.CustomerSeen[suffix][customer] = true
if n.CustomerTotalSeen == nil {
n.CustomerTotalSeen = map[string]bool{}
} }
n.CustomerTotalSeen[customer] = true
} }
} }
for _, suffix := range productPerformancePeriodSuffixes() {
n.MarketSeen = productPerformanceAddSeenStrings(n.MarketSeen, suffix, productPerformanceStringSliceFromMap(row, "__market_keys_"+suffix))
n.CustomerSeen = productPerformanceAddSeenStrings(n.CustomerSeen, suffix, productPerformanceStringSliceFromMap(row, "__customer_keys_"+suffix))
}
} }
func appendProductPerformanceGroupedSnapshotNodes(out *[]map[string]any, nodes map[string]*productPerformanceGroupedSnapshotNode, order []string) { func appendProductPerformanceGroupedSnapshotNodes(out *[]map[string]any, nodes map[string]*productPerformanceGroupedSnapshotNode, order []string) {
@@ -5387,7 +5795,7 @@ func (n *productPerformanceGroupedSnapshotNode) snapshotRow() map[string]any {
row[key] = state.Sum / state.Count row[key] = state.Sum / state.Count
} }
} }
applyProductPerformanceDistinctSpread(row, n.Market90Seen, n.MarketTotalSeen, n.Customer90Seen, n.CustomerTotalSeen) applyProductPerformanceDistinctSpread(row, n.MarketSeen, n.CustomerSeen)
deriveProductPerformanceGroupMetrics(row, n.Field) deriveProductPerformanceGroupMetrics(row, n.Field)
if bucket := n.dominantBucket(); bucket != "" { if bucket := n.dominantBucket(); bucket != "" {
row["performance_bucket"] = bucket row["performance_bucket"] = bucket
@@ -5508,14 +5916,12 @@ func clearProductPerformanceGroupDimensions(row map[string]any, groupField, grou
func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any { func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any {
out := map[string]any{} out := map[string]any{}
market90Seen := map[string]bool{} marketSeen := map[string]map[string]bool{}
marketTotalSeen := map[string]bool{} customerSeen := map[string]map[string]bool{}
customer90Seen := map[string]bool{}
customerTotalSeen := map[string]bool{}
for _, row := range rows { for _, row := range rows {
addProductPerformanceDistinctSpread(row, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen) addProductPerformanceDistinctSpread(row, marketSeen, customerSeen)
for key, value := range row { for key, value := range row {
if key == "row_key" || key == "key" { if key == "row_key" || key == "key" || isProductPerformanceInternalGroupField(key) {
continue continue
} }
if isProductPerformanceMarginField(key) { if isProductPerformanceMarginField(key) {
@@ -5541,6 +5947,9 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m
} }
for _, row := range rows { for _, row := range rows {
for key := range row { for key := range row {
if isProductPerformanceInternalGroupField(key) {
continue
}
if isProductPerformanceMarginField(key) { if isProductPerformanceMarginField(key) {
continue continue
} }
@@ -5549,62 +5958,120 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m
} }
} }
} }
applyProductPerformanceDistinctSpread(out, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen) applyProductPerformanceDistinctSpread(out, marketSeen, customerSeen)
deriveProductPerformanceGroupMetrics(out, groupField) deriveProductPerformanceGroupMetrics(out, groupField)
out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket") out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket")
return out return out
} }
func addProductPerformanceDistinctSpread(row map[string]any, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen map[string]bool) { func addProductPerformanceDistinctSpread(row map[string]any, marketSeen, customerSeen map[string]map[string]bool) {
market := displayProductPerformanceMarketName(stringFromMap(row, "market_key")) market := displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
if market != "" && market != "STOK" { if market != "" && market != "STOK" {
if floatFromMap(row, "sales_usd_90d") > 0 { for _, suffix := range productPerformancePeriodSuffixes() {
market90Seen[market] = true if floatFromMap(row, "sales_usd_"+suffix) > 0 {
if marketSeen[suffix] == nil {
marketSeen[suffix] = map[string]bool{}
}
marketSeen[suffix][market] = true
} }
if floatFromMap(row, "sales_usd_total") > 0 {
marketTotalSeen[market] = true
} }
} }
customer := strings.TrimSpace(stringFromMap(row, "customer_code")) customer := strings.TrimSpace(stringFromMap(row, "customer_code"))
if customer != "" && customer != "-" { if customer != "" && customer != "-" {
if floatFromMap(row, "sales_usd_90d") > 0 { for _, suffix := range productPerformancePeriodSuffixes() {
customer90Seen[customer] = true if floatFromMap(row, "sales_usd_"+suffix) > 0 {
if customerSeen[suffix] == nil {
customerSeen[suffix] = map[string]bool{}
}
customerSeen[suffix][customer] = true
}
}
}
for _, suffix := range productPerformancePeriodSuffixes() {
marketSeen = productPerformanceAddSeenStrings(marketSeen, suffix, productPerformanceStringSliceFromMap(row, "__market_keys_"+suffix))
customerSeen = productPerformanceAddSeenStrings(customerSeen, suffix, productPerformanceStringSliceFromMap(row, "__customer_keys_"+suffix))
}
}
func applyProductPerformanceDistinctSpread(row map[string]any, marketSeen, customerSeen map[string]map[string]bool) {
for _, suffix := range productPerformancePeriodSuffixes() {
marketField := "market_count_" + suffix
customerField := "customer_count_" + suffix
if seen := marketSeen[suffix]; len(seen) > 0 {
row[marketField] = len(seen)
}
if seen := customerSeen[suffix]; len(seen) > 0 {
row[customerField] = len(seen)
}
if floatFromMap(row, "sales_usd_"+suffix) > 0 && (suffix == "90d" || suffix == "total") {
if intFromMap(row, marketField) == 0 {
row[marketField] = 1
}
if intFromMap(row, customerField) == 0 {
if suffix == "total" {
row[customerField] = maxInt(1, intFromMap(row, "customer_count_90d"))
} else {
row[customerField] = 1
}
} }
if floatFromMap(row, "sales_usd_total") > 0 {
customerTotalSeen[customer] = true
} }
} }
} }
func applyProductPerformanceDistinctSpread(row map[string]any, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen map[string]bool) { func productPerformanceAddSeenStrings(seen map[string]map[string]bool, suffix string, values []string) map[string]map[string]bool {
if len(market90Seen) > 0 { if len(values) == 0 {
row["market_count_90d"] = len(market90Seen) return seen
} }
if len(marketTotalSeen) > 0 { if seen == nil {
row["market_count_total"] = len(marketTotalSeen) seen = map[string]map[string]bool{}
} }
if len(customer90Seen) > 0 { if seen[suffix] == nil {
row["customer_count_90d"] = len(customer90Seen) seen[suffix] = map[string]bool{}
} }
if len(customerTotalSeen) > 0 { for _, value := range values {
row["customer_count_total"] = len(customerTotalSeen) value = strings.TrimSpace(value)
if value == "" || value == "-" {
continue
} }
if floatFromMap(row, "sales_usd_90d") > 0 { seen[suffix][value] = true
if intFromMap(row, "market_count_90d") == 0 {
row["market_count_90d"] = 1
} }
if intFromMap(row, "customer_count_90d") == 0 { return seen
row["customer_count_90d"] = 1 }
func productPerformanceStringSliceFromMap(row map[string]any, field string) []string {
value, ok := row[field]
if !ok || value == nil {
return nil
}
switch v := value.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
text := strings.TrimSpace(fmt.Sprint(item))
if text != "" {
out = append(out, text)
} }
} }
if floatFromMap(row, "sales_usd_total") > 0 { return out
if intFromMap(row, "market_count_total") == 0 { case string:
row["market_count_total"] = maxInt(1, intFromMap(row, "market_count_90d")) text := strings.TrimSpace(v)
if text == "" {
return nil
} }
if intFromMap(row, "customer_count_total") == 0 { var parsed []string
row["customer_count_total"] = maxInt(1, intFromMap(row, "customer_count_90d")) if strings.HasPrefix(text, "[") && json.Unmarshal([]byte(text), &parsed) == nil {
return parsed
}
return []string{text}
default:
return nil
} }
} }
func isProductPerformanceInternalGroupField(field string) bool {
return strings.HasPrefix(field, "__")
} }
func productPerformanceGroupRecommendation(row map[string]any, groupField string, count int) string { func productPerformanceGroupRecommendation(row map[string]any, groupField string, count int) string {
@@ -5711,18 +6178,55 @@ func productPerformanceMapVariantKey(row map[string]any) string {
func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) { func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) {
normalizeProductPerformanceCostFields(out) normalizeProductPerformanceCostFields(out)
for _, suffix := range []string{"90d", "180d", "365d", "total"} { isGroup := strings.TrimSpace(groupField) != ""
if isGroup {
out["group_field"] = groupField
}
preservedCustomerScores := map[string]float64{}
preservedProductScores := map[string]float64{}
preservedStockTurnovers := map[string]float64{}
for _, suffix := range productPerformancePeriodSuffixes() {
if score, ok := productPerformanceOptionalFloat(out, "customer_score_"+suffix); ok {
preservedCustomerScores[suffix] = score
}
if score, ok := productPerformanceOptionalFloat(out, "performance_score_"+suffix); ok {
preservedProductScores[suffix] = score
}
if isGroup {
if turnover, ok := productPerformanceOptionalFloat(out, "stock_turnover_"+suffix); ok {
preservedStockTurnovers[suffix] = turnover
}
}
}
if score, ok := productPerformanceOptionalFloat(out, "performance_score"); ok {
if _, exists := preservedProductScores["90d"]; !exists {
preservedProductScores["90d"] = score
}
}
for _, suffix := range productPerformancePeriodSuffixes() {
sales := floatFromMap(out, "sales_usd_"+suffix) sales := floatFromMap(out, "sales_usd_"+suffix)
qty := floatFromMap(out, "sales_qty_"+suffix) qty := floatFromMap(out, "sales_qty_"+suffix)
stockQty := floatFromMap(out, "stock_qty") stockQty := floatFromMap(out, "stock_qty")
turnoverBase := floatFromMap(out, "avg_stock_"+suffix) turnoverBase := floatFromMap(out, "avg_stock_"+suffix)
if turnoverBase <= 0 { if turnoverBase <= 0 && !isGroup {
turnoverBase = stockQty turnoverBase = stockQty
} }
if turnoverBase > 0 { days := productPerformancePeriodDays(out, suffix)
out["stock_turnover_"+suffix] = qty / turnoverBase avgDaily := floatFromMap(out, "avg_daily_sales_"+suffix)
} else { if days > 0 {
out["stock_turnover_"+suffix] = 0 avgDaily = qty / days
out["avg_daily_sales_"+suffix] = avgDaily
}
if avgDaily > 0 && turnoverBase > 0 {
out["stock_days_"+suffix] = turnoverBase / avgDaily
} else if qty <= 0 && stockQty > 0 {
out["stock_days_"+suffix] = 9999
} else if _, ok := out["stock_days_"+suffix]; !ok {
out["stock_days_"+suffix] = 0
}
out["stock_turnover_"+suffix] = productPerformanceAnnualizedStockTurnover(qty, turnoverBase, days)
if turnover, ok := preservedStockTurnovers[suffix]; ok {
out["stock_turnover_"+suffix] = turnover
} }
if qty > 0 { if qty > 0 {
out["avg_price_usd_"+suffix] = sales / qty out["avg_price_usd_"+suffix] = sales / qty
@@ -5775,37 +6279,19 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string)
if _, ok := out["net_stock_after_order"]; ok || orderQty > 0 { if _, ok := out["net_stock_after_order"]; ok || orderQty > 0 {
out["net_stock_after_order"] = floatFromMap(out, "stock_qty") - orderQty out["net_stock_after_order"] = floatFromMap(out, "stock_qty") - orderQty
} }
if _, ok := out["customer_score_90d"]; !ok { for _, suffix := range productPerformancePeriodSuffixes() {
out["customer_score_90d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "90d")) if score, ok := preservedCustomerScores[suffix]; ok {
} out["customer_score_"+suffix] = score
if _, ok := out["customer_score_180d"]; !ok {
out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d"))
}
if _, ok := out["customer_score_365d"]; !ok {
out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d"))
}
if _, ok := out["customer_score_total"]; !ok {
out["customer_score_total"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "total"))
}
if _, ok := out["performance_score_90d"]; !ok {
out["performance_score_90d"] = productPerformanceSalesPeriodScore(out, "90d")
}
if _, ok := out["performance_score_180d"]; !ok {
out["performance_score_180d"] = productPerformanceSalesPeriodScore(out, "180d")
}
if _, ok := out["performance_score_365d"]; !ok {
out["performance_score_365d"] = productPerformanceSalesPeriodScore(out, "365d")
}
if _, ok := out["performance_score_total"]; !ok {
out["performance_score_total"] = productPerformanceSalesPeriodScore(out, "total")
}
if _, ok := out["performance_score"]; !ok {
if score, exists := productPerformanceOptionalFloat(out, "performance_score_90d"); exists {
out["performance_score"] = score
} else { } else {
out["performance_score"] = productPerformanceGroupScore(out, groupField) out["customer_score_"+suffix] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, suffix))
}
if score, ok := preservedProductScores[suffix]; ok {
out["performance_score_"+suffix] = score
} else {
out["performance_score_"+suffix] = productPerformanceSalesPeriodScore(out, suffix)
} }
} }
out["performance_score"] = out["performance_score_90d"]
if floatFromMap(out, "order_qty") > 0 || floatFromMap(out, "order_usd") > 0 { if floatFromMap(out, "order_qty") > 0 || floatFromMap(out, "order_usd") > 0 {
score := productPerformanceOrderGroupScore(out) score := productPerformanceOrderGroupScore(out)
out["performance_score_90d"] = score out["performance_score_90d"] = score
@@ -5832,11 +6318,69 @@ func normalizeProductPerformanceCostFields(row map[string]any) {
} }
} }
normalize("cost_price_usd", "base_price_usd") normalize("cost_price_usd", "base_price_usd")
for _, suffix := range []string{"90d", "180d", "365d", "total"} { for _, suffix := range productPerformancePeriodSuffixes() {
normalize("cost_price_usd_"+suffix, "base_price_usd_"+suffix) normalize("cost_price_usd_"+suffix, "base_price_usd_"+suffix)
} }
} }
func productPerformancePeriodSuffixes() []string {
return []string{"90d", "180d", "365d", "total"}
}
func productPerformancePeriodDays(row map[string]any, suffix string) float64 {
switch suffix {
case "90d":
return 90
case "180d":
return 180
case "365d":
return 360
case "total":
start := parseProductPerformanceDate(stringFromMap(row, "period_start"))
if start.IsZero() {
start = time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
}
end := parseProductPerformanceDate(stringFromMap(row, "period_end"))
if end.IsZero() {
end = parseProductPerformanceDate(stringFromMap(row, "kpi_date"))
}
if end.IsZero() || end.Before(start) {
return 0
}
return end.Sub(start).Hours()/24 + 1
default:
return 0
}
}
const productPerformanceStockTurnoverYearDays = 360.0
func productPerformanceAnnualizedStockTurnover(salesQty, avgStock, periodDays float64) float64 {
if salesQty <= 0 || avgStock <= 0 {
return 0
}
raw := salesQty / avgStock
if periodDays <= 0 {
return raw
}
return raw * productPerformanceStockTurnoverYearDays / periodDays
}
func parseProductPerformanceDate(value string) time.Time {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}
}
if len(value) >= len("2006-01-02") {
value = value[:len("2006-01-02")]
}
t, err := time.Parse("2006-01-02", value)
if err != nil {
return time.Time{}
}
return t
}
func productPerformanceOptionalFloat(row map[string]any, field string) (float64, bool) { func productPerformanceOptionalFloat(row map[string]any, field string) (float64, bool) {
value, ok := row[field] value, ok := row[field]
if !ok { if !ok {
@@ -5898,8 +6442,12 @@ func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float
salesQty := floatFromMap(row, "sales_qty_"+suffix) salesQty := floatFromMap(row, "sales_qty_"+suffix)
stockTurnover := floatFromMap(row, "stock_turnover_"+suffix) stockTurnover := floatFromMap(row, "stock_turnover_"+suffix)
if stockTurnover == 0 { if stockTurnover == 0 {
if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 { avgStock := floatFromMap(row, "avg_stock_"+suffix)
stockTurnover = salesQty / stockQty if avgStock <= 0 && strings.TrimSpace(stringFromMap(row, "group_field")) == "" {
avgStock = floatFromMap(row, "stock_qty")
}
if avgStock > 0 {
stockTurnover = productPerformanceAnnualizedStockTurnover(salesQty, avgStock, productPerformancePeriodDays(row, suffix))
} }
} }
return productPerformanceProductScore( return productPerformanceProductScore(
@@ -5910,6 +6458,7 @@ func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float
stockTurnover, stockTurnover,
productPerformancePeriodCount(row, "market_count", suffix), productPerformancePeriodCount(row, "market_count", suffix),
productPerformancePeriodCount(row, "customer_count", suffix), productPerformancePeriodCount(row, "customer_count", suffix),
productPerformancePeriodDays(row, suffix),
) )
} }
@@ -5953,19 +6502,54 @@ func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 {
return productPerformanceCustomerScore(suffix, salesUSD, margin, productCount, salesQty) return productPerformanceCustomerScore(suffix, salesUSD, margin, productCount, salesQty)
} }
func productPerformanceProductScore(suffix string, salesUSD, salesIndex, margin, stockTurnover, marketCount, customerCount float64) float64 { func productPerformanceProductScore(suffix string, salesUSD, salesIndex, margin, stockTurnover, marketCount, customerCount float64, periodDays ...float64) float64 {
if salesUSD <= 0 { if salesUSD <= 0 {
return 1 return 1
} }
revenueScore := productPerformanceRevenueScore(suffix, salesUSD, salesIndex, productPerformanceProductRevenueTarget(suffix)) days := productPerformanceScorePeriodDays(suffix, periodDays...)
revenueScore := productPerformanceRevenueScore(suffix, salesUSD, salesIndex, productPerformanceProductRevenueTargetForDays(suffix, days))
score := 0.30*productPerformanceMarginComponentScore(margin) + score := 0.30*productPerformanceMarginComponentScore(margin) +
0.20*productPerformanceRatioScore(stockTurnover, 1.50) + 0.20*productPerformanceRatioScore(stockTurnover, productPerformanceStockTurnoverTarget(suffix)) +
0.20*revenueScore + 0.20*revenueScore +
0.15*productPerformanceRatioScore(marketCount, 8) + 0.05*productPerformanceRatioScore(marketCount, productPerformanceMarketSpreadTarget(suffix)) +
0.15*productPerformanceRatioScore(customerCount, 25) 0.25*productPerformanceRatioScore(customerCount, productPerformanceCustomerSpreadTargetForDays(suffix, days))
return productPerformanceRoundScore(score) return productPerformanceRoundScore(score)
} }
func productPerformanceStockTurnoverTarget(suffix string) float64 {
return 4
}
func productPerformanceMarketSpreadTarget(suffix string) float64 {
switch suffix {
case "90d":
return 3
case "180d":
return 3
case "365d":
return 6
default:
return 6
}
}
func productPerformanceCustomerSpreadTarget(suffix string) float64 {
return productPerformanceCustomerSpreadTargetForDays(suffix, productPerformanceScorePeriodDays(suffix))
}
func productPerformanceCustomerSpreadTargetForDays(suffix string, periodDays float64) float64 {
switch suffix {
case "90d":
return 8
case "180d":
return 20
case "365d":
return 50
default:
return 20 * productPerformanceTotalPeriodMultiplier(periodDays)
}
}
func productPerformanceCustomerScore(suffix string, salesUSD, margin, productGroupCount, salesQty float64) float64 { func productPerformanceCustomerScore(suffix string, salesUSD, margin, productGroupCount, salesQty float64) float64 {
if salesUSD <= 0 { if salesUSD <= 0 {
return 1 return 1
@@ -5989,7 +6573,7 @@ func productPerformanceRelativeIndex(value, average float64) float64 {
} }
func productPerformanceMarginComponentScore(margin float64) float64 { func productPerformanceMarginComponentScore(margin float64) float64 {
return productPerformanceRatioScore(maxFloat(0, margin), 0.40) return productPerformanceRatioScore(maxFloat(0, margin), 0.45)
} }
func productPerformanceRatioScore(value, target float64) float64 { func productPerformanceRatioScore(value, target float64) float64 {
@@ -6010,18 +6594,49 @@ func productPerformanceRoundScore(score float64) float64 {
} }
func productPerformanceProductRevenueTarget(suffix string) float64 { func productPerformanceProductRevenueTarget(suffix string) float64 {
return productPerformanceProductRevenueTargetForDays(suffix, productPerformanceScorePeriodDays(suffix))
}
func productPerformanceProductRevenueTargetForDays(suffix string, periodDays float64) float64 {
switch suffix { switch suffix {
case "180d": case "180d":
return 20000 return 50000
case "365d": case "365d":
return 40000 return 100000
case "total": case "total":
return 120000 return 50000 * productPerformanceTotalPeriodMultiplier(periodDays)
default: default:
return 10000 return 25000
} }
} }
func productPerformanceScorePeriodDays(suffix string, periodDays ...float64) float64 {
if len(periodDays) > 0 && periodDays[0] > 0 {
return periodDays[0]
}
switch suffix {
case "90d":
return 90
case "180d":
return 180
case "365d":
return 360
case "total":
return productPerformancePeriodDays(map[string]any{
"kpi_date": time.Now().Format("2006-01-02"),
}, "total")
default:
return 0
}
}
func productPerformanceTotalPeriodMultiplier(periodDays float64) float64 {
if periodDays <= 0 {
periodDays = 180
}
return maxFloat(1, periodDays/180.0)
}
func productPerformanceCustomerRevenueTarget(suffix string) float64 { func productPerformanceCustomerRevenueTarget(suffix string) float64 {
switch suffix { switch suffix {
case "180d": case "180d":
@@ -6131,7 +6746,7 @@ func sanitizeProductPerformanceGroupLevels(levels []string) []string {
func defaultProductPerformanceGroupLevels(mode string) []string { func defaultProductPerformanceGroupLevels(mode string) []string {
switch mode { switch mode {
case "sales_color_yaka_market_customer": case "sales_color_yaka_market_customer":
return []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "country", "market_key", "customer_segment", "customer_code", "customer_name"} return []string{"urun_ana_grubu", "color_yaka", "market_key", "customer_code", "customer_name", "urun_alt_grubu", "product_code"}
case "product_detail": case "product_detail":
return []string{"urun_alt_grubu", "product_code", "color_yaka", "market_key"} return []string{"urun_alt_grubu", "product_code", "color_yaka", "market_key"}
case "idle": case "idle":
@@ -6172,7 +6787,16 @@ func mapGroupValue(row map[string]any, field string) string {
} }
func shouldSumProductPerformanceField(field string) bool { func shouldSumProductPerformanceField(field string) bool {
return strings.HasSuffix(field, "_qty") || return strings.HasPrefix(field, "sales_qty_") ||
strings.HasPrefix(field, "sales_usd_") ||
strings.HasPrefix(field, "avg_daily_sales_") ||
strings.HasPrefix(field, "gross_profit_") ||
strings.HasPrefix(field, "invoice_count_") ||
strings.HasPrefix(field, "market_count_") ||
strings.HasPrefix(field, "customer_count_") ||
strings.HasPrefix(field, "product_group_count_") ||
strings.HasPrefix(field, "product_count_") ||
strings.HasSuffix(field, "_qty") ||
strings.HasSuffix(field, "_usd") || strings.HasSuffix(field, "_usd") ||
strings.HasSuffix(field, "_count") || strings.HasSuffix(field, "_count") ||
strings.HasSuffix(field, "_value_usd") || strings.HasSuffix(field, "_value_usd") ||
@@ -6189,6 +6813,9 @@ func shouldSumProductPerformanceField(field string) bool {
} }
func shouldAverageProductPerformanceField(field string) bool { func shouldAverageProductPerformanceField(field string) bool {
if strings.HasPrefix(field, "avg_daily_sales_") {
return false
}
return strings.HasPrefix(field, "avg_") || return strings.HasPrefix(field, "avg_") ||
strings.HasPrefix(field, "unit_") || strings.HasPrefix(field, "unit_") ||
strings.HasPrefix(field, "base_price") || strings.HasPrefix(field, "base_price") ||
@@ -6206,6 +6833,9 @@ func productPerformanceMetricWeightField(field string) string {
if productPerformanceUsesCostWeight(field) { if productPerformanceUsesCostWeight(field) {
return "__product_cost_weight" return "__product_cost_weight"
} }
if strings.HasPrefix(field, "stock_turnover") {
return "sales_qty_" + productPerformanceScoreSuffix(field)
}
if strings.Contains(field, "_180d") { if strings.Contains(field, "_180d") {
return "sales_qty_180d" return "sales_qty_180d"
} }
@@ -6264,23 +6894,10 @@ func productPerformanceScoreSuffix(field string) string {
} }
func productPerformanceScoreWeight(row map[string]any, suffix string) float64 { func productPerformanceScoreWeight(row map[string]any, suffix string) float64 {
if weight := floatFromMap(row, "sales_usd_"+suffix); weight > 0 {
return weight
}
if weight := floatFromMap(row, "sales_qty_"+suffix); weight > 0 { if weight := floatFromMap(row, "sales_qty_"+suffix); weight > 0 {
return weight return weight
} }
stockQty := floatFromMap(row, "stock_qty") return 0
if stockQty <= 0 {
return 1
}
if cost := floatFromMap(row, "cost_price_usd_"+suffix); cost > 0 {
return stockQty * cost
}
if cost := floatFromMap(row, "cost_price_usd"); cost > 0 {
return stockQty * cost
}
return stockQty
} }
func productPerformanceCostWeight(row map[string]any) float64 { func productPerformanceCostWeight(row map[string]any) float64 {
+94 -25
View File
@@ -486,6 +486,13 @@ SalesAgg AS (
SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days') AS sales_usd_365d, SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days') AS sales_usd_365d,
SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01') AS sales_usd_total, SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01') AS sales_usd_total,
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days' AND COALESCE(sales_usd, 0) > 0) AS customer_count_90d, COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days' AND COALESCE(sales_usd, 0) > 0) AS customer_count_90d,
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days' AND COALESCE(sales_usd, 0) > 0) AS customer_count_180d,
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days' AND COALESCE(sales_usd, 0) > 0) AS customer_count_365d,
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(sales_usd, 0) > 0) AS customer_count_total,
MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS first_sale_date_90d,
MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS first_sale_date_180d,
MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days') AS first_sale_date_365d,
MIN(sales_date) FILTER (WHERE sales_date >= DATE '2022-01-01') AS first_sale_date_total,
MAX(sales_date) AS last_sale_date, MAX(sales_date) AS last_sale_date,
MAX(last_ref_number) AS last_ref_number MAX(last_ref_number) AS last_ref_number
FROM mk_product_performance_sales_daily FROM mk_product_performance_sales_daily
@@ -500,7 +507,9 @@ Scope AS (
item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu,
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total, sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total,
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total, sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total,
customer_count_90d, last_sale_date, last_ref_number customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total,
first_sale_date_90d, first_sale_date_180d, first_sale_date_365d, first_sale_date_total,
last_sale_date, last_ref_number
FROM SalesAgg FROM SalesAgg
UNION ALL UNION ALL
SELECT SELECT
@@ -528,6 +537,13 @@ Scope AS (
0 AS sales_usd_365d, 0 AS sales_usd_365d,
0 AS sales_usd_total, 0 AS sales_usd_total,
0 AS customer_count_90d, 0 AS customer_count_90d,
0 AS customer_count_180d,
0 AS customer_count_365d,
0 AS customer_count_total,
NULL::date AS first_sale_date_90d,
NULL::date AS first_sale_date_180d,
NULL::date AS first_sale_date_365d,
NULL::date AS first_sale_date_total,
NULL::date AS last_sale_date, NULL::date AS last_sale_date,
'' AS last_ref_number '' AS last_ref_number
FROM LatestStock ls FROM LatestStock ls
@@ -570,10 +586,10 @@ Base AS (
COALESCE(s.sales_qty_180d, 0) / 180.0 AS avg_daily_sales_180d, COALESCE(s.sales_qty_180d, 0) / 180.0 AS avg_daily_sales_180d,
COALESCE(s.sales_qty_365d, 0) / 360.0 AS avg_daily_sales_365d, COALESCE(s.sales_qty_365d, 0) / 360.0 AS avg_daily_sales_365d,
COALESCE(s.sales_qty_total, 0) / GREATEST(1, ($1::date - DATE '2022-01-01') + 1) AS avg_daily_sales_total, COALESCE(s.sales_qty_total, 0) / GREATEST(1, ($1::date - DATE '2022-01-01') + 1) AS avg_daily_sales_total,
(COALESCE(s90.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_90d, (COALESCE(s90.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_90d,
(COALESCE(s180.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_180d, (COALESCE(s180.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_180d,
(COALESCE(s365.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_365d, (COALESCE(s365.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_365d,
(COALESCE(stotal.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_total, (COALESCE(stotal.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_total,
CASE WHEN COALESCE(s.sales_qty_90d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_90d, 0) / NULLIF(s.sales_qty_90d, 0) END AS avg_price_usd_90d, CASE WHEN COALESCE(s.sales_qty_90d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_90d, 0) / NULLIF(s.sales_qty_90d, 0) END AS avg_price_usd_90d,
CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0) END AS avg_price_usd_180d, CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0) END AS avg_price_usd_180d,
COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_90d, COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_90d,
@@ -590,11 +606,47 @@ Base AS (
CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0)) - COALESCE(pd.base_price_usd, 0) END AS unit_profit_base_180d CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0)) - COALESCE(pd.base_price_usd, 0) END AS unit_profit_base_180d
FROM Scope s FROM Scope s
LEFT JOIN LatestStock ls ON ls.product_code=s.product_code AND ls.color_code=s.color_code AND ls.yaka_kodu=s.yaka_kodu LEFT JOIN LatestStock ls ON ls.product_code=s.product_code AND ls.color_code=s.color_code AND ls.yaka_kodu=s.yaka_kodu
LEFT JOIN Stock90Start s90 ON s90.product_code=s.product_code AND s90.color_code=s.color_code AND s90.yaka_kodu=s.yaka_kodu
LEFT JOIN Stock180Start s180 ON s180.product_code=s.product_code AND s180.color_code=s.color_code AND s180.yaka_kodu=s.yaka_kodu
LEFT JOIN Stock365Start s365 ON s365.product_code=s.product_code AND s365.color_code=s.color_code AND s365.yaka_kodu=s.yaka_kodu
LEFT JOIN StockTotalStart stotal ON stotal.product_code=s.product_code AND stotal.color_code=s.color_code AND stotal.yaka_kodu=s.yaka_kodu
LEFT JOIN PriceDim pd ON pd.product_code=s.product_code LEFT JOIN PriceDim pd ON pd.product_code=s.product_code
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') WHERE upper(translate(btrim(COALESCE(NULLIF(s.urun_ilk_grubu,''), pd.urun_ilk_grubu, '')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
), ),
Spread AS ( Spread AS (
@@ -602,15 +654,20 @@ Spread AS (
product_code, product_code,
color_code, color_code,
yaka_kodu, yaka_kodu,
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) AS market_count_90d COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_90d, 0) > 0) AS market_count_90d,
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_180d, 0) > 0) AS market_count_180d,
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_365d, 0) > 0) AS market_count_365d,
COUNT(DISTINCT NULLIF(NULLIF(market_key, 'STOK'), '')) FILTER (WHERE COALESCE(sales_usd_total, 0) > 0) AS market_count_total
FROM SalesAgg FROM SalesAgg
WHERE COALESCE(sales_usd_90d, 0) > 0
GROUP BY product_code, color_code, yaka_kodu GROUP BY product_code, color_code, yaka_kodu
), ),
Market AS ( Market AS (
SELECT SELECT
market_key, market_key,
AVG(NULLIF(sales_qty_90d, 0)) AS market_avg_sales_qty_90d, AVG(NULLIF(sales_qty_90d, 0)) AS market_avg_sales_qty_90d,
AVG(NULLIF(sales_qty_180d, 0)) AS market_avg_sales_qty_180d,
AVG(NULLIF(sales_qty_365d, 0)) AS market_avg_sales_qty_365d,
AVG(NULLIF(sales_qty_total, 0)) AS market_avg_sales_qty_total,
AVG(NULLIF(avg_price_usd_90d, 0)) AS market_avg_price_usd_90d, AVG(NULLIF(avg_price_usd_90d, 0)) AS market_avg_price_usd_90d,
AVG(NULLIF(gross_margin_90d, 0)) AS market_avg_margin_90d AVG(NULLIF(gross_margin_90d, 0)) AS market_avg_margin_90d
FROM Base FROM Base
@@ -620,15 +677,21 @@ Scored AS (
SELECT SELECT
b.*, b.*,
COALESCE(sp.market_count_90d, 0) AS market_count_90d, COALESCE(sp.market_count_90d, 0) AS market_count_90d,
COALESCE(sp.market_count_180d, 0) AS market_count_180d,
COALESCE(sp.market_count_365d, 0) AS market_count_365d,
COALESCE(sp.market_count_total, 0) AS market_count_total,
CASE WHEN b.avg_daily_sales_90d = 0 THEN 0 ELSE b.avg_stock_90d / NULLIF(b.avg_daily_sales_90d, 0) END AS stock_days_90d, CASE WHEN b.avg_daily_sales_90d = 0 THEN 0 ELSE b.avg_stock_90d / NULLIF(b.avg_daily_sales_90d, 0) END AS stock_days_90d,
CASE WHEN b.avg_daily_sales_180d = 0 THEN 0 ELSE b.avg_stock_180d / NULLIF(b.avg_daily_sales_180d, 0) END AS stock_days_180d, CASE WHEN b.avg_daily_sales_180d = 0 THEN 0 ELSE b.avg_stock_180d / NULLIF(b.avg_daily_sales_180d, 0) END AS stock_days_180d,
CASE WHEN b.avg_daily_sales_365d = 0 THEN 0 ELSE b.avg_stock_365d / NULLIF(b.avg_daily_sales_365d, 0) END AS stock_days_365d, CASE WHEN b.avg_daily_sales_365d = 0 THEN 0 ELSE b.avg_stock_365d / NULLIF(b.avg_daily_sales_365d, 0) END AS stock_days_365d,
CASE WHEN b.avg_daily_sales_total = 0 THEN 0 ELSE b.avg_stock_total / NULLIF(b.avg_daily_sales_total, 0) END AS stock_days_total, CASE WHEN b.avg_daily_sales_total = 0 THEN 0 ELSE b.avg_stock_total / NULLIF(b.avg_daily_sales_total, 0) END AS stock_days_total,
CASE WHEN b.avg_stock_90d = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(b.avg_stock_90d, 0) END AS stock_turnover_90d, CASE WHEN b.avg_stock_90d = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(b.avg_stock_90d, 0) * 4.0 END AS stock_turnover_90d,
CASE WHEN b.avg_stock_180d = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(b.avg_stock_180d, 0) END AS stock_turnover_180d, CASE WHEN b.avg_stock_180d = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(b.avg_stock_180d, 0) * 2.0 END AS stock_turnover_180d,
CASE WHEN b.avg_stock_365d = 0 THEN 0 ELSE b.sales_qty_365d / NULLIF(b.avg_stock_365d, 0) END AS stock_turnover_365d, CASE WHEN b.avg_stock_365d = 0 THEN 0 ELSE b.sales_qty_365d / NULLIF(b.avg_stock_365d, 0) END AS stock_turnover_365d,
CASE WHEN b.avg_stock_total = 0 THEN 0 ELSE b.sales_qty_total / NULLIF(b.avg_stock_total, 0) END AS stock_turnover_total, CASE WHEN b.avg_stock_total = 0 THEN 0 ELSE b.sales_qty_total / NULLIF(b.avg_stock_total, 0) * 360.0 / GREATEST(1, ($1::date - DATE '2022-01-01') + 1) END AS stock_turnover_total,
CASE WHEN COALESCE(m.market_avg_sales_qty_90d, 0) = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(m.market_avg_sales_qty_90d, 0) END AS sales_index_90d, CASE WHEN COALESCE(m.market_avg_sales_qty_90d, 0) = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(m.market_avg_sales_qty_90d, 0) END AS sales_index_90d,
CASE WHEN COALESCE(m.market_avg_sales_qty_180d, 0) = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(m.market_avg_sales_qty_180d, 0) END AS sales_index_180d,
CASE WHEN COALESCE(m.market_avg_sales_qty_365d, 0) = 0 THEN 0 ELSE b.sales_qty_365d / NULLIF(m.market_avg_sales_qty_365d, 0) END AS sales_index_365d,
CASE WHEN COALESCE(m.market_avg_sales_qty_total, 0) = 0 THEN 0 ELSE b.sales_qty_total / NULLIF(m.market_avg_sales_qty_total, 0) END AS sales_index_total,
CASE WHEN COALESCE(m.market_avg_price_usd_90d, 0) = 0 THEN 0 ELSE b.avg_price_usd_90d / NULLIF(m.market_avg_price_usd_90d, 0) END AS price_index_90d, CASE WHEN COALESCE(m.market_avg_price_usd_90d, 0) = 0 THEN 0 ELSE b.avg_price_usd_90d / NULLIF(m.market_avg_price_usd_90d, 0) END AS price_index_90d,
CASE WHEN COALESCE(m.market_avg_margin_90d, 0) = 0 THEN 0 ELSE b.gross_margin_90d / NULLIF(m.market_avg_margin_90d, 0) END AS margin_index_90d CASE WHEN COALESCE(m.market_avg_margin_90d, 0) = 0 THEN 0 ELSE b.gross_margin_90d / NULLIF(m.market_avg_margin_90d, 0) END AS margin_index_90d
FROM Base b FROM Base b
@@ -640,11 +703,14 @@ INSERT INTO mk_product_performance_kpi_daily (
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty,
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total, sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total,
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total, avg_daily_sales_90d, avg_daily_sales_180d, avg_daily_sales_365d, avg_daily_sales_total, sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total, avg_daily_sales_90d, avg_daily_sales_180d, avg_daily_sales_365d, avg_daily_sales_total,
avg_stock_90d, avg_stock_180d, avg_stock_365d, avg_stock_total,
stock_days_90d, stock_days_180d, stock_days_365d, stock_days_total, stock_turnover_90d, stock_turnover_180d, stock_turnover_365d, stock_turnover_total, stock_days_90d, stock_days_180d, stock_days_365d, stock_days_total, stock_turnover_90d, stock_turnover_180d, stock_turnover_365d, stock_turnover_total,
avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try, avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try,
gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d, gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d,
unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d, market_count_90d, customer_count_90d, unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d,
sales_index_90d, price_index_90d, margin_index_90d, performance_score, market_count_90d, market_count_180d, market_count_365d, market_count_total,
customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total,
sales_index_90d, sales_index_180d, sales_index_365d, sales_index_total, price_index_90d, margin_index_90d, performance_score,
performance_bucket, recommendation, last_sale_date, last_ref_number, updated_at performance_bucket, recommendation, last_sale_date, last_ref_number, updated_at
) )
SELECT SELECT
@@ -653,22 +719,25 @@ SELECT
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty,
COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_180d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0), COALESCE(sales_qty_total, 0), COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_180d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0), COALESCE(sales_qty_total, 0),
COALESCE(sales_usd_30d, 0), COALESCE(sales_usd_90d, 0), COALESCE(sales_usd_180d, 0), COALESCE(sales_usd_365d, 0), COALESCE(sales_usd_total, 0), COALESCE(avg_daily_sales_90d, 0), COALESCE(avg_daily_sales_180d, 0), COALESCE(avg_daily_sales_365d, 0), COALESCE(avg_daily_sales_total, 0), COALESCE(sales_usd_30d, 0), COALESCE(sales_usd_90d, 0), COALESCE(sales_usd_180d, 0), COALESCE(sales_usd_365d, 0), COALESCE(sales_usd_total, 0), COALESCE(avg_daily_sales_90d, 0), COALESCE(avg_daily_sales_180d, 0), COALESCE(avg_daily_sales_365d, 0), COALESCE(avg_daily_sales_total, 0),
COALESCE(avg_stock_90d, 0), COALESCE(avg_stock_180d, 0), COALESCE(avg_stock_365d, 0), COALESCE(avg_stock_total, 0),
COALESCE(stock_days_90d, 0), COALESCE(stock_days_180d, 0), COALESCE(stock_days_365d, 0), COALESCE(stock_days_total, 0), COALESCE(stock_turnover_90d, 0), COALESCE(stock_turnover_180d, 0), COALESCE(stock_turnover_365d, 0), COALESCE(stock_turnover_total, 0), COALESCE(stock_days_90d, 0), COALESCE(stock_days_180d, 0), COALESCE(stock_days_365d, 0), COALESCE(stock_days_total, 0), COALESCE(stock_turnover_90d, 0), COALESCE(stock_turnover_180d, 0), COALESCE(stock_turnover_365d, 0), COALESCE(stock_turnover_total, 0),
COALESCE(avg_price_usd_90d, 0), COALESCE(avg_price_usd_180d, 0), COALESCE(cost_price_usd, 0), COALESCE(base_price_usd, 0), COALESCE(base_price_try, 0), COALESCE(avg_price_usd_90d, 0), COALESCE(avg_price_usd_180d, 0), COALESCE(cost_price_usd, 0), COALESCE(base_price_usd, 0), COALESCE(base_price_try, 0),
COALESCE(gross_profit_usd_90d, 0), COALESCE(gross_profit_usd_180d, 0), COALESCE(gross_margin_90d, 0), COALESCE(gross_margin_180d, 0), COALESCE(gross_profit_usd_90d, 0), COALESCE(gross_profit_usd_180d, 0), COALESCE(gross_margin_90d, 0), COALESCE(gross_margin_180d, 0),
COALESCE(unit_profit_cost_90d, 0), COALESCE(unit_profit_cost_180d, 0), COALESCE(unit_profit_base_90d, 0), COALESCE(unit_profit_base_180d, 0), COALESCE(market_count_90d, 0), COALESCE(customer_count_90d, 0), COALESCE(unit_profit_cost_90d, 0), COALESCE(unit_profit_cost_180d, 0), COALESCE(unit_profit_base_90d, 0), COALESCE(unit_profit_base_180d, 0),
COALESCE(sales_index_90d, 0), COALESCE(price_index_90d, 0), COALESCE(margin_index_90d, 0), COALESCE(market_count_90d, 0), COALESCE(market_count_180d, 0), COALESCE(market_count_365d, 0), COALESCE(market_count_total, 0),
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 CASE WHEN COALESCE(sales_usd_90d, 0) <= 0 THEN 1 ELSE
ROUND( ROUND(
LEAST(35, COALESCE(sales_index_90d, 0) * 18) LEAST(30, GREATEST(COALESCE(gross_margin_90d, 0), 0) / 0.45 * 30)
+ LEAST(25, GREATEST(COALESCE(gross_margin_90d, 0), 0) * 50) + LEAST(20, COALESCE(stock_turnover_90d, 0) / 4.0 * 20)
+ LEAST(20, COALESCE(customer_count_90d, 0) * 2) + LEAST(20, COALESCE(sales_usd_90d, 0) / 25000.0 * 20)
+ LEAST(10, COALESCE(market_count_90d, 0) * 3) + LEAST(5, COALESCE(market_count_90d, 0) / 3.0 * 5)
+ CASE WHEN COALESCE(stock_days_90d, 0) BETWEEN 10 AND 90 THEN 10 WHEN COALESCE(stock_days_90d, 0) > 180 THEN -10 ELSE 0 END + LEAST(25, COALESCE(customer_count_90d, 0) / 8.0 * 25)
, 4) , 4)
END AS performance_score, END AS performance_score,
CASE CASE
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'YILDIZ_URUN' WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.45 THEN 'YILDIZ_URUN'
WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'STOKSUZ_TALEP' WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'STOKSUZ_TALEP'
WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'STOK_RISKI' WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'STOK_RISKI'
WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'FIYAT_BASKISI' WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'FIYAT_BASKISI'
@@ -676,7 +745,7 @@ SELECT
ELSE 'TAKIP' ELSE 'TAKIP'
END AS performance_bucket, END AS performance_bucket,
CASE CASE
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'Piyasa ustu satis ve iyi marj: stok ve fiyat gucu takip edilmeli.' WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.45 THEN 'Piyasa ustu satis ve iyi marj: stok ve fiyat gucu takip edilmeli.'
WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: uretim/satin alma planina alinmali.' 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(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) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa ustunde ve satis zayif: fiyat revizyonu degerlendirilmeli.'
+92 -5
View File
@@ -793,6 +793,82 @@ ORDER BY SonIsEmri.dteIslemTarihi DESC, LTRIM(RTRIM(R.sMModelKodu)) ASC
return uretimDB.QueryContext(ctx, sqlText, fromDate, search) return uretimDB.QueryContext(ctx, sqlText, fromDate, search)
} }
type ArgeBaggiDepotEntryTotals struct {
Ceket float64
Pantolon float64
Yelek float64
}
// GetArgeBaggiDepotEntryTotals returns operation 985/986/987 quantities keyed
// by production work order. The requested work orders are queried in one batch
// to avoid an ArgeBAGGI round trip for every list row.
func GetArgeBaggiDepotEntryTotals(ctx context.Context, argeDB *sql.DB, workOrders []string) (map[string]ArgeBaggiDepotEntryTotals, error) {
out := make(map[string]ArgeBaggiDepotEntryTotals)
if argeDB == nil || len(workOrders) == 0 {
return out, nil
}
seen := make(map[string]struct{}, len(workOrders))
values := make([]string, 0, len(workOrders))
for _, raw := range workOrders {
value := strings.TrimSpace(raw)
if value == "" || value == "0" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
values = append(values, value)
}
if len(values) == 0 {
return out, nil
}
placeholders := make([]string, 0, len(values))
args := make([]any, 0, len(values))
for i, value := range values {
placeholders = append(placeholders, fmt.Sprintf("@p%d", i+1))
args = append(args, value)
}
sqlText := fmt.Sprintf(`
SELECT
LTRIM(RTRIM(CONVERT(VARCHAR(64), MN.model_adi))) AS IsEmriNo,
SUM(CASE WHEN O.oper_num = 985 THEN CONVERT(float, B.adet) ELSE 0 END) AS CeketDepoGiris,
SUM(CASE WHEN O.oper_num = 986 THEN CONVERT(float, B.adet) ELSE 0 END) AS PantolonDepoGiris,
SUM(CASE WHEN O.oper_num = 987 THEN CONVERT(float, B.adet) ELSE 0 END) AS YelekDepoGiris
FROM dbo.is_acma_bar B
INNER JOIN dbo.is_acma I ON I.is_num = B.is_num
INNER JOIN dbo.model_def MD ON I.imdef_num = MD.mdef_num
INNER JOIN dbo.oper_urun_no O ON B.oper_num = O.oper_num
INNER JOIN dbo.model_num MN ON I.model_num = MN.model_num
INNER JOIN dbo.bant_no BN ON I.bant_num = BN.bant_no
INNER JOIN dbo.model_oper MO ON O.oper_num = MO.oper_num AND MD.mdef_num = MO.model_num
INNER JOIN dbo.model_oper_tkp_def TD ON MO.tkp_cik = TD.tkp_cik
WHERE B.tarih >= '2025-01-01'
AND TD.tkp_cik IN (1,2,3,4,5)
AND O.oper_num IN (985,986,987)
AND LTRIM(RTRIM(CONVERT(VARCHAR(64), MN.model_adi))) IN (%s)
GROUP BY LTRIM(RTRIM(CONVERT(VARCHAR(64), MN.model_adi)))
`, strings.Join(placeholders, ","))
rows, err := argeDB.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var workOrder string
var totals ArgeBaggiDepotEntryTotals
if err := rows.Scan(&workOrder, &totals.Ceket, &totals.Pantolon, &totals.Yelek); err != nil {
return nil, err
}
out[strings.TrimSpace(workOrder)] = totals
}
return out, rows.Err()
}
func GetProductionHasCostProducts( func GetProductionHasCostProducts(
ctx context.Context, ctx context.Context,
uretimDB *sql.DB, uretimDB *sql.DB,
@@ -816,12 +892,22 @@ WITH SonOnMaliyet AS (
FROM dbo.spUrtOnMLMas M FROM dbo.spUrtOnMLMas M
WHERE LTRIM(RTRIM(M.UrunKodu)) <> '' WHERE LTRIM(RTRIM(M.UrunKodu)) <> ''
), ),
SonSiparis AS ( SonSiparisSirali AS (
SELECT SELECT
LTRIM(RTRIM(sMModelKodu)) AS UrunKodu, LTRIM(RTRIM(SD.sMModelKodu)) AS UrunKodu,
MAX(dteIslemTarihi) AS SonSiparisTarihi SD.dteIslemTarihi AS SonSiparisTarihi,
FROM dbo.spUrtSiparisDet SM.nUrtSiparisNo,
GROUP BY LTRIM(RTRIM(sMModelKodu)) ROW_NUMBER() OVER (
PARTITION BY LTRIM(RTRIM(SD.sMModelKodu))
ORDER BY SD.dteIslemTarihi DESC, SD.nUrtSiparisID DESC
) AS rn
FROM dbo.spUrtSiparisDet SD
INNER JOIN dbo.spUrtSiparis SM ON SM.nUrtSiparisID = SD.nUrtSiparisID
),
SonSiparis AS (
SELECT UrunKodu, SonSiparisTarihi, nUrtSiparisNo
FROM SonSiparisSirali
WHERE rn = 1
) )
SELECT SELECT
CASE CASE
@@ -831,6 +917,7 @@ SELECT
THEN N'FASON ICIN URETILEN' THEN N'FASON ICIN URETILEN'
ELSE N'Tanimsiz' ELSE N'Tanimsiz'
END AS UretimSekli, END AS UretimSekli,
RTRIM(CONVERT(VARCHAR(32), ISNULL(SS.nUrtSiparisNo, 0))) AS nUrtSiparisNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(OM.nOnMLNo, 0))) AS nOnMLNo, RTRIM(CONVERT(VARCHAR(32), ISNULL(OM.nOnMLNo, 0))) AS nOnMLNo,
LTRIM(RTRIM(ISNULL(OM.UrunKodu, ''))) AS UrunKodu, LTRIM(RTRIM(ISNULL(OM.UrunKodu, ''))) AS UrunKodu,
ISNULL(OM.UrunAdi, '') AS UrunAdi, ISNULL(OM.UrunAdi, '') AS UrunAdi,
+143 -23
View File
@@ -51,6 +51,10 @@ type productPerformanceExcelVariant struct {
UrunAnaGrubu string UrunAnaGrubu string
UrunAltGrubu string UrunAltGrubu string
StockQty float64 StockQty float64
AvgStock90 float64
AvgStock180 float64
AvgStock365 float64
AvgStockTotal float64
BasePriceUSD float64 BasePriceUSD float64
CostPriceUSD float64 CostPriceUSD float64
@@ -188,6 +192,10 @@ func (v *productPerformanceExcelVariant) addProductRow(row models.ProductPerform
v.SalesUSD90 += row.SalesUSD90 v.SalesUSD90 += row.SalesUSD90
v.SalesUSD180 += row.SalesUSD180 v.SalesUSD180 += row.SalesUSD180
v.SalesUSD365 += row.SalesUSD365 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.SalesQtyTotalProduct += row.SalesQtyTotal
v.SalesUSDTotalProduct += row.SalesUSDTotal v.SalesUSDTotalProduct += row.SalesUSDTotal
v.MarketCount90 = productPerformanceExcelMaxInt(v.MarketCount90, row.MarketCount90) v.MarketCount90 = productPerformanceExcelMaxInt(v.MarketCount90, row.MarketCount90)
@@ -407,9 +415,9 @@ func productPerformanceExcelPeriodColumns(label, suffix string, values func(*pro
qty, usd, _, _ := values(v) qty, usd, _, _ := values(v)
return productPerformanceExcelAvgPrice(usd, qty) return productPerformanceExcelAvgPrice(usd, qty)
}), }),
numberExcelColumn(label+" Stok Devir Hızı", "number", func(v *productPerformanceExcelVariant) any { numberExcelColumn(label+" Yıllık Stok Devir", "number", func(v *productPerformanceExcelVariant) any {
qty, _, _, _ := values(v) qty, _, _, _ := values(v)
return productPerformanceExcelStockTurnover(qty, v.StockQty) return productPerformanceExcelStockTurnover(qty, productPerformanceExcelAvgStock(v, suffix), productPerformanceExcelPeriodDays(v, suffix))
}), }),
numberExcelColumn(label+" Taban Maliyet USD", "number", func(v *productPerformanceExcelVariant) any { return v.BasePriceUSD }), 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+" Çıplak Maliyet USD", "number", func(v *productPerformanceExcelVariant) any { return v.CostPriceUSD }),
@@ -439,7 +447,8 @@ func productPerformanceExcelPeriodColumns(label, suffix string, values func(*pro
}), }),
numberExcelColumn(label+" Ürün Skor", "number", func(v *productPerformanceExcelVariant) any { numberExcelColumn(label+" Ürün Skor", "number", func(v *productPerformanceExcelVariant) any {
qty, usd, markets, customers := values(v) qty, usd, markets, customers := values(v)
return productPerformanceExcelProductScore(suffix, usd, productPerformanceExcelStockTurnover(qty, v.StockQty), float64(markets), float64(customers), productPerformanceExcelMargin(usd, qty, v.CostPriceUSD)) days := productPerformanceExcelPeriodDays(v, suffix)
return productPerformanceExcelProductScore(suffix, usd, productPerformanceExcelStockTurnover(qty, productPerformanceExcelAvgStock(v, suffix), days), float64(markets), float64(customers), productPerformanceExcelMargin(usd, qty, v.CostPriceUSD), days)
}), }),
} }
} }
@@ -657,13 +666,13 @@ func productPerformanceExcelSortNumber(row *productPerformanceExcelVariant, sort
case "avg_price_usd_total": case "avg_price_usd_total":
return productPerformanceExcelAvgPrice(row.totalUSD(), row.totalQty()) return productPerformanceExcelAvgPrice(row.totalUSD(), row.totalQty())
case "stock_turnover_90d": case "stock_turnover_90d":
return productPerformanceExcelStockTurnover(row.SalesQty90, row.StockQty) return productPerformanceExcelStockTurnover(row.SalesQty90, productPerformanceExcelAvgStock(row, "90d"), productPerformanceExcelPeriodDays(row, "90d"))
case "stock_turnover_180d": case "stock_turnover_180d":
return productPerformanceExcelStockTurnover(row.SalesQty180, row.StockQty) return productPerformanceExcelStockTurnover(row.SalesQty180, productPerformanceExcelAvgStock(row, "180d"), productPerformanceExcelPeriodDays(row, "180d"))
case "stock_turnover_365d": case "stock_turnover_365d":
return productPerformanceExcelStockTurnover(row.SalesQty365, row.StockQty) return productPerformanceExcelStockTurnover(row.SalesQty365, productPerformanceExcelAvgStock(row, "365d"), productPerformanceExcelPeriodDays(row, "365d"))
case "stock_turnover_total": case "stock_turnover_total":
return productPerformanceExcelStockTurnover(row.totalQty(), row.StockQty) return productPerformanceExcelStockTurnover(row.totalQty(), productPerformanceExcelAvgStock(row, "total"), productPerformanceExcelPeriodDays(row, "total"))
case "base_price_usd": case "base_price_usd":
return row.BasePriceUSD return row.BasePriceUSD
case "cost_price_usd": case "cost_price_usd":
@@ -677,9 +686,11 @@ func productPerformanceExcelSortNumber(row *productPerformanceExcelVariant, sort
case "customer_count_total": case "customer_count_total":
return float64(row.CustomerCountTotal) return float64(row.CustomerCountTotal)
case "performance_score", "performance_score_90d": case "performance_score", "performance_score_90d":
return productPerformanceExcelProductScore("90d", row.SalesUSD90, productPerformanceExcelStockTurnover(row.SalesQty90, row.StockQty), float64(row.marketCount90()), float64(row.CustomerCount90), productPerformanceExcelMargin(row.SalesUSD90, row.SalesQty90, row.CostPriceUSD)) 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": case "performance_score_total":
return productPerformanceExcelProductScore("total", row.totalUSD(), productPerformanceExcelStockTurnover(row.totalQty(), row.StockQty), float64(row.marketCountTotal()), float64(row.CustomerCountTotal), productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD)) 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: default:
return 0 return 0
} }
@@ -795,7 +806,7 @@ func productPerformanceExcelStatus(row *productPerformanceExcelVariant) string {
return "Stok Riski" return "Stok Riski"
case productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD) < 0: case productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD) < 0:
return "Fiyat Baskısı" return "Fiyat Baskısı"
case productPerformanceExcelProductScore("total", row.totalUSD(), productPerformanceExcelStockTurnover(row.totalQty(), row.StockQty), float64(row.marketCountTotal()), float64(row.CustomerCountTotal), productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD)) >= 70: 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" return "Yıldız Ürün"
default: default:
return productPerformanceExcelBucketLabel(row.PerformanceBucket) return productPerformanceExcelBucketLabel(row.PerformanceBucket)
@@ -881,11 +892,55 @@ func productPerformanceExcelAvgPrice(salesUSD, qty float64) float64 {
return salesUSD / qty return salesUSD / qty
} }
func productPerformanceExcelStockTurnover(salesQty, stockQty float64) float64 { const productPerformanceExcelStockTurnoverYearDays = 360.0
if stockQty <= 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 return 0
} }
return salesQty / stockQty 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 { func productPerformanceExcelGrossProfit(salesUSD, qty, unitCost float64) float64 {
@@ -902,37 +957,102 @@ func productPerformanceExcelMargin(salesUSD, qty, unitCost float64) float64 {
return productPerformanceExcelGrossProfit(salesUSD, qty, unitCost) / salesUSD return productPerformanceExcelGrossProfit(salesUSD, qty, unitCost) / salesUSD
} }
func productPerformanceExcelProductScore(suffix string, salesUSD, stockTurnover, marketCount, customerCount, margin float64) float64 { func productPerformanceExcelProductScore(suffix string, salesUSD, stockTurnover, marketCount, customerCount, margin float64, periodDays ...float64) float64 {
if salesUSD <= 0 { if salesUSD <= 0 {
return 1 return 1
} }
revenueScore := productPerformanceExcelRatioScore(salesUSD, productPerformanceExcelProductRevenueTarget(suffix)) days := productPerformanceExcelScorePeriodDays(suffix, periodDays...)
revenueScore := productPerformanceExcelRatioScore(salesUSD, productPerformanceExcelProductRevenueTargetForDays(suffix, days))
score := 0.30*productPerformanceExcelMarginScore(margin) + score := 0.30*productPerformanceExcelMarginScore(margin) +
0.20*productPerformanceExcelRatioScore(stockTurnover, 1.50) + 0.20*productPerformanceExcelRatioScore(stockTurnover, 4) +
0.20*revenueScore + 0.20*revenueScore +
0.15*productPerformanceExcelRatioScore(marketCount, 8) + 0.05*productPerformanceExcelRatioScore(marketCount, productPerformanceExcelMarketSpreadTarget(suffix)) +
0.15*productPerformanceExcelRatioScore(customerCount, 25) 0.25*productPerformanceExcelRatioScore(customerCount, productPerformanceExcelCustomerSpreadTargetForDays(suffix, days))
return productPerformanceExcelRoundScore(score) 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 { func productPerformanceExcelProductRevenueTarget(suffix string) float64 {
return productPerformanceExcelProductRevenueTargetForDays(suffix, productPerformanceExcelScorePeriodDays(suffix))
}
func productPerformanceExcelProductRevenueTargetForDays(suffix string, periodDays float64) float64 {
switch suffix { switch suffix {
case "180d": case "180d":
return 20000 return 50000
case "365d": case "365d":
return 40000 return 100000
case "total": case "total":
return 120000 return 50000 * productPerformanceExcelTotalPeriodMultiplier(periodDays)
default: default:
return 10000 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 { func productPerformanceExcelMarginScore(margin float64) float64 {
if margin < 0 { if margin < 0 {
margin = 0 margin = 0
} }
return productPerformanceExcelRatioScore(margin, 0.40) return productPerformanceExcelRatioScore(margin, 0.45)
} }
func productPerformanceExcelRatioScore(value, target float64) float64 { func productPerformanceExcelRatioScore(value, target float64) float64 {
+47
View File
@@ -158,6 +158,26 @@ func GetProductionNoCostProductsHandler(w http.ResponseWriter, r *http.Request)
return return
} }
if argeDB := db.GetArgeBaggiDB(); argeDB != nil && len(list) > 0 {
workOrders := make([]string, 0, len(list))
for i := range list {
workOrders = append(workOrders, list[i].UrtSiparisNo)
}
argeCtx, cancelArge := context.WithTimeout(r.Context(), 15*time.Second)
totalsByWorkOrder, argeErr := queries.GetArgeBaggiDepotEntryTotals(argeCtx, argeDB, workOrders)
cancelArge()
if argeErr != nil {
log.Printf("[ProductionNoCost] ArgeBAGGI enrichment error: %v", argeErr)
} else {
for i := range list {
totals := totalsByWorkOrder[strings.TrimSpace(list[i].UrtSiparisNo)]
list[i].CeketDepoGiris = totals.Ceket
list[i].PantolonDepoGiris = totals.Pantolon
list[i].YelekDepoGiris = totals.Yelek
}
}
}
_ = json.NewEncoder(w).Encode(list) _ = json.NewEncoder(w).Encode(list)
} }
@@ -191,6 +211,7 @@ func GetProductionHasCostProductsHandler(w http.ResponseWriter, r *http.Request)
var item models.ProductionHasCostProductRow var item models.ProductionHasCostProductRow
if err := rows.Scan( if err := rows.Scan(
&item.UretimSekli, &item.UretimSekli,
&item.UrtSiparisNo,
&item.NOnMLNo, &item.NOnMLNo,
&item.UrunKodu, &item.UrunKodu,
&item.UrunAdi, &item.UrunAdi,
@@ -219,6 +240,26 @@ func GetProductionHasCostProductsHandler(w http.ResponseWriter, r *http.Request)
return return
} }
if argeDB := db.GetArgeBaggiDB(); argeDB != nil && len(list) > 0 {
workOrders := make([]string, 0, len(list))
for i := range list {
workOrders = append(workOrders, list[i].UrtSiparisNo)
}
argeCtx, cancelArge := context.WithTimeout(r.Context(), 15*time.Second)
totalsByWorkOrder, argeErr := queries.GetArgeBaggiDepotEntryTotals(argeCtx, argeDB, workOrders)
cancelArge()
if argeErr != nil {
log.Printf("[ProductionHasCost] ArgeBAGGI enrichment error: %v", argeErr)
} else {
for i := range list {
totals := totalsByWorkOrder[strings.TrimSpace(list[i].UrtSiparisNo)]
list[i].CeketDepoGiris = totals.Ceket
list[i].PantolonDepoGiris = totals.Pantolon
list[i].YelekDepoGiris = totals.Yelek
}
}
}
_ = json.NewEncoder(w).Encode(list) _ = json.NewEncoder(w).Encode(list)
} }
@@ -1427,6 +1468,12 @@ func postProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
totalUSD := 0.0 totalUSD := 0.0
totalEUR := 0.0 totalEUR := 0.0
for _, r := range req.Detail.Upserts { for _, r := range req.Detail.Upserts {
// The top header in the detail screen only includes rows marked
// "Maliyete Dahil". Persist that exact business total to the master
// record as well; excluded detail rows must not inflate the cost.
if r.MaliyeteDahil != 1 {
continue
}
qty := r.LMiktar qty := r.LMiktar
if qty < 0 { if qty < 0 {
qty = 0 qty = 0
+15 -1
View File
@@ -1,8 +1,20 @@
// quasar.config.js // quasar.config.js
import { defineConfig } from '#q-app/wrappers' import { defineConfig } from '#q-app/wrappers'
import { execSync } from 'node:child_process'
function readGitBuildInfo () {
try {
const commitCount = execSync('git rev-list --count HEAD', { encoding: 'utf8' }).trim()
const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim()
return { version: `v1.0.${commitCount}`, commit: commitHash }
} catch {
return { version: 'v1.0.0', commit: 'local' }
}
}
export default defineConfig(() => { export default defineConfig(() => {
const apiBaseUrl = (process.env.VITE_API_BASE_URL || '/api').trim() const apiBaseUrl = (process.env.VITE_API_BASE_URL || '/api').trim()
const gitBuildInfo = readGitBuildInfo()
return { return {
@@ -36,7 +48,9 @@ export default defineConfig(() => {
build: { build: {
vueRouterMode: 'hash', vueRouterMode: 'hash',
env: { env: {
VITE_API_BASE_URL: apiBaseUrl VITE_API_BASE_URL: apiBaseUrl,
APP_VERSION: gitBuildInfo.version,
APP_COMMIT: gitBuildInfo.commit
}, },
esbuildTarget: { esbuildTarget: {
+21
View File
@@ -74,6 +74,10 @@
</q-form> </q-form>
</q-card> </q-card>
<div class="login-version" :title="`Commit: ${appCommit}`">
Baggi BSS {{ appVersion }}
</div>
<!-- 🔐 FORGOT PASSWORD --> <!-- 🔐 FORGOT PASSWORD -->
<q-dialog v-model="forgotOpen" persistent> <q-dialog v-model="forgotOpen" persistent>
<q-card style="width:420px; max-width:90vw"> <q-card style="width:420px; max-width:90vw">
@@ -129,6 +133,8 @@ import api from 'src/services/api'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const $q = useQuasar() const $q = useQuasar()
const appVersion = process.env.APP_VERSION || 'v1.0.0'
const appCommit = process.env.APP_COMMIT || 'local'
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
@@ -266,6 +272,21 @@ async function sendResetMail () {
.full-width { .full-width {
width: 100%; width: 100%;
} }
.login-version {
position: absolute;
left: 16px;
bottom: 12px;
z-index: 2;
padding: 4px 8px;
border-radius: 6px;
color: rgba(45, 45, 45, 0.75);
background: rgba(255, 255, 255, 0.68);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.2px;
user-select: text;
}
/* =============================== /* ===============================
LOGIN INPUT TEXT COLOR (GOLD) LOGIN INPUT TEXT COLOR (GOLD)
=============================== */ =============================== */
+2 -2
View File
@@ -1289,12 +1289,12 @@ function normalizeUploadsPath (storagePath) {
function resolveProductImageUrl (item) { function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return '' if (!item || typeof item !== 'object') return ''
const contentUrl = toText(item.content_url || item.ContentURL)
if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl
const thumbUrl = toText(item.thumb_url || item.thumbUrl) const thumbUrl = toText(item.thumb_url || item.thumbUrl)
if (thumbUrl) return thumbUrl if (thumbUrl) return thumbUrl
const fullUrl = toText(item.full_url || item.fullUrl) const fullUrl = toText(item.full_url || item.fullUrl)
if (fullUrl) return fullUrl if (fullUrl) return fullUrl
const contentUrl = toText(item.content_url || item.ContentURL)
if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage) const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage)
if (uploadsPath) return uploadsPath if (uploadsPath) return uploadsPath
const fileName = toText(item.file_name || item.FileName) const fileName = toText(item.file_name || item.FileName)
+358 -127
View File
@@ -35,6 +35,15 @@
:disable="pageBusy" :disable="pageBusy"
class="period-selector bg-white q-px-xs q-py-none" class="period-selector bg-white q-px-xs q-py-none"
/> />
<q-btn
dense
outline
color="primary"
icon="view_column"
:label="detailColumnsHidden ? 'Detay Kolonları Göster' : 'Detay Kolonları Gizle'"
:disable="pageBusy"
@click="detailColumnsHidden = !detailColumnsHidden"
/>
<q-btn-dropdown <q-btn-dropdown
v-model="detailLevelMenuOpen" v-model="detailLevelMenuOpen"
split split
@@ -90,20 +99,6 @@
</button> </button>
</div> </div>
<div v-if="activeTab === 'product_detail'" class="main-group-bar q-mb-xs">
<button
v-for="option in detailMainGroupOptions"
:key="option.value"
type="button"
:class="['main-group-button', { active: selectedDetailMainGroup === option.value }]"
:disabled="pageBusy"
@click="selectedDetailMainGroup = option.value"
>
<span>{{ option.label }}</span>
<small>{{ formatNumber(option.stock_qty, 0) }} stok</small>
</button>
</div>
<div ref="topScrollbarRef" class="performance-top-scrollbar q-mb-xs"> <div ref="topScrollbarRef" class="performance-top-scrollbar q-mb-xs">
<div ref="topScrollbarInnerRef" class="performance-top-scrollbar-inner"></div> <div ref="topScrollbarInnerRef" class="performance-top-scrollbar-inner"></div>
</div> </div>
@@ -113,9 +108,14 @@
flat flat
bordered bordered
row-key="row_key" row-key="row_key"
class="performance-table sticky-dim-table sticky-dim-7 bg-white" :class="[
'performance-table',
'sticky-dim-table',
detailColumnsHidden ? 'sticky-dim-4' : 'sticky-dim-7',
'bg-white'
]"
:rows="filteredGeneralRows" :rows="filteredGeneralRows"
:columns="generalColumns" :columns="visibleGeneralColumns"
:loading="activeTableLoading" :loading="activeTableLoading"
v-model:pagination="tablePagination.general" v-model:pagination="tablePagination.general"
virtual-scroll virtual-scroll
@@ -560,6 +560,7 @@
'performance-table', 'performance-table',
'product-breakdown-table', 'product-breakdown-table',
'bg-white', 'bg-white',
{ 'compact-detail-columns': detailColumnsHidden },
activeTab === 'product_detail' ? 'product-detail-table' : 'product-summary-table' activeTab === 'product_detail' ? 'product-detail-table' : 'product-summary-table'
]" ]"
:rows="displayProductKpiTableRows" :rows="displayProductKpiTableRows"
@@ -1603,6 +1604,7 @@ const loadingNow = ref(0)
const loadingStage = ref('') const loadingStage = ref('')
const generalRowsLoaded = ref(false) const generalRowsLoaded = ref(false)
const detailLevelMenuOpen = ref(false) const detailLevelMenuOpen = ref(false)
const detailColumnsHidden = ref(false)
const topScrollbarRef = ref(null) const topScrollbarRef = ref(null)
const topScrollbarInnerRef = ref(null) const topScrollbarInnerRef = ref(null)
let backendGroupedTimer = null let backendGroupedTimer = null
@@ -1619,7 +1621,7 @@ const lastManualExpandedGroupKey = ref('')
const selectedExpandLevelKeysByTab = reactive({ const selectedExpandLevelKeysByTab = reactive({
products: ['urun_ilk_grubu', 'askili_yan', 'kategori'], products: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
product_detail: ['urun_alt_grubu'], product_detail: ['urun_alt_grubu'],
sales_color_yaka_market_customer: ['color_yaka'], sales_color_yaka_market_customer: ['urun_ana_grubu', 'color_yaka'],
idle: ['urun_ilk_grubu', 'askili_yan', 'kategori'], idle: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
sales_product_country_segment_market_customer: ['urun_ilk_grubu', 'askili_yan', 'kategori'], sales_product_country_segment_market_customer: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'], sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
@@ -1675,8 +1677,7 @@ const productPerformanceExcelExportFilterFields = new Set([
]) ])
const performanceTabs = [ const performanceTabs = [
{ name: 'products', icon: 'dashboard', label: 'Genel Özet KPI' }, { name: 'products', icon: 'dashboard', label: 'Genel Özet KPI' },
{ name: 'product_detail', icon: 'category', label: 'Detay KPI' }, { name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Ürün Ana Grubu > Renk/Yaka > Piyasa > Müşteri' },
{ name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Renk/Yaka > Piyasa > Müşteri' },
{ name: 'idle', icon: 'warning', label: 'Atıl Stok / Maliyet' }, { name: 'idle', icon: 'warning', label: 'Atıl Stok / Maliyet' },
{ name: 'sales_product_country_segment_market_customer', icon: 'account_tree', label: 'Ürün > Ülke > Segment > Piyasa > Müşteri' }, { name: 'sales_product_country_segment_market_customer', icon: 'account_tree', label: 'Ürün > Ülke > Segment > Piyasa > Müşteri' },
{ name: 'sales_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' }, { name: 'sales_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' },
@@ -1816,10 +1817,10 @@ const columns = [
{ name: 'stock_days_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 1), align: 'right', sortable: true }, { name: 'stock_days_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 1), align: 'right', sortable: true },
{ name: 'stock_days_365d', label: '360G Stok Gün', field: row => formatNumber(row.stock_days_365d, 1), align: 'right', sortable: true }, { name: 'stock_days_365d', label: '360G Stok Gün', field: row => formatNumber(row.stock_days_365d, 1), align: 'right', sortable: true },
{ name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true }, { name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true }, { name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_180d', label: '180G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true }, { name: 'stock_turnover_180d', label: '180G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_365d', label: '360G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true }, { name: 'stock_turnover_365d', label: '360G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_total', label: 'Genel Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true }, { name: 'stock_turnover_total', label: 'Genel Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' }, { name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' }, { name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' }, { name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' },
@@ -1849,7 +1850,6 @@ const columns = [
{ name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true }, { name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true }, { name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
{ name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true }, { name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true },
{ name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 2), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' }, { name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
] ]
@@ -2024,7 +2024,32 @@ const productColumns = computed(() => orderedMetricColumns(columns.filter(visibl
const productDetailColumns = computed(() => orderedMetricColumns(columns const productDetailColumns = computed(() => orderedMetricColumns(columns
.filter(col => !['urun_ilk_grubu', 'askili_yan', 'kategori'].includes(col.name)) .filter(col => !['urun_ilk_grubu', 'askili_yan', 'kategori'].includes(col.name))
.filter(visiblePeriodColumn))) .filter(visiblePeriodColumn)))
const activeProductColumns = computed(() => activeTab.value === 'product_detail' ? productDetailColumns.value : productColumns.value) const detailColumnNames = new Set([
'item_description',
'kategori',
'askili_yan',
'urun_ilk_grubu',
'urun_ana_grubu',
'urun_alt_grubu',
'period_start',
'period_end',
'country',
'customer_segment',
'customer_code',
'customer_name',
'first_sale_date',
'last_sale_date',
'last_ref_number'
])
function applyDetailColumnVisibility (sourceColumns) {
if (!detailColumnsHidden.value) return sourceColumns
return sourceColumns.filter(col => !detailColumnNames.has(col.name))
}
const visibleProductColumns = computed(() => applyDetailColumnVisibility(productColumns.value))
const visibleProductDetailColumns = computed(() => applyDetailColumnVisibility(productDetailColumns.value))
const activeProductColumns = computed(() => activeTab.value === 'product_detail' ? visibleProductDetailColumns.value : visibleProductColumns.value)
const generalColumns = [ const generalColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' }, { name: 'image', label: 'Foto', field: 'image', align: 'center' },
@@ -2059,13 +2084,15 @@ const generalColumns = [
{ name: 'customer_count_total', label: 'Toplam Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true }, { name: 'customer_count_total', label: 'Toplam Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'invoice_count_total', label: 'Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true }, { name: 'invoice_count_total', label: 'Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
{ name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true }, { name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true },
{ name: 'performance_score', label: 'Genel Skor', field: row => formatNumber(row.performance_score, 2), align: 'right', sortable: true }, { name: 'performance_score_total', label: 'Genel Skor', field: row => formatNumber(row.performance_score_total ?? row.performance_score, 2), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true }, { name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'first_sale_date', label: 'İlk Satış', field: 'first_sale_date', align: 'left', sortable: true }, { name: 'first_sale_date', label: 'İlk Satış', field: 'first_sale_date', align: 'left', sortable: true },
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true }, { name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
] ]
const visibleGeneralColumns = computed(() => applyDetailColumnVisibility(generalColumns))
const orderAnalysisColumns = [ const orderAnalysisColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' }, { name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
@@ -2205,7 +2232,7 @@ const idleColumns = [
{ name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true }, { name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, { name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true }, { name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true }, { name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' }, { name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
] ]
@@ -2288,13 +2315,13 @@ const salesBreakdownColumns = [
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, { name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: 'avg_price_usd_90d', align: 'right', sortable: true }, { name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true }, { name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'customer_count_180d', label: '180G Müşteri', field: row => formatNumber(row.customer_count_180d, 0), align: 'right', sortable: true }, { name: 'customer_count_180d', label: '180G Müşteri', field: row => formatNumber(row.customer_count_180d, 0), align: 'right', sortable: true },
{ name: 'invoice_count_180d', label: '180G Fatura', field: row => formatNumber(row.invoice_count_180d, 0), align: 'right', sortable: true }, { name: 'invoice_count_180d', label: '180G Fatura', field: row => formatNumber(row.invoice_count_180d, 0), align: 'right', sortable: true },
{ name: 'sales_qty_180d', label: '180G Adet', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true }, { name: 'sales_qty_180d', label: '180G Adet', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_180d', label: '180G USD', field: row => formatMoney(row.sales_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'sales_usd_180d', label: '180G USD', field: row => formatMoney(row.sales_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_180d', label: '180G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true }, { name: 'stock_turnover_180d', label: '180G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_180d', label: '180G Taban', field: row => formatMoney(row.base_price_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'base_price_usd_180d', label: '180G Taban', field: row => formatMoney(row.base_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_180d', label: '180G Çıplak', field: row => formatMoney(row.cost_price_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_180d', label: '180G Çıplak', field: row => formatMoney(row.cost_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_180d', label: '180G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_180d', label: '180G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_180d, 'USD'), align: 'right', sortable: true },
@@ -2305,7 +2332,7 @@ const salesBreakdownColumns = [
{ name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true }, { name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_365d', label: '360G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'sales_usd_365d', label: '360G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_365d', label: '360G Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true }, { name: 'stock_turnover_365d', label: '360G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_365d', label: '360G Taban', field: row => formatMoney(row.base_price_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'base_price_usd_365d', label: '360G Taban', field: row => formatMoney(row.base_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_365d', label: '360G Çıplak', field: row => formatMoney(row.cost_price_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_365d', label: '360G Çıplak', field: row => formatMoney(row.cost_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_365d', label: '360G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_365d', label: '360G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_365d, 'USD'), align: 'right', sortable: true },
@@ -2318,7 +2345,7 @@ const salesBreakdownColumns = [
{ name: 'sales_qty_total', label: 'Genel Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true }, { name: 'sales_qty_total', label: 'Genel Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_total', label: 'Genel USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'sales_usd_total', label: 'Genel USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_total', label: 'Genel Stok Devir Hızı', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true }, { name: 'stock_turnover_total', label: 'Genel Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_total', label: 'Genel Taban', field: row => formatMoney(row.base_price_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'base_price_usd_total', label: 'Genel Taban', field: row => formatMoney(row.base_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_total', label: 'Genel Çıplak', field: row => formatMoney(row.cost_price_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_total', label: 'Genel Çıplak', field: row => formatMoney(row.cost_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
@@ -2327,7 +2354,6 @@ const salesBreakdownColumns = [
{ name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true }, { name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true },
{ name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 2), align: 'right', sortable: true }, { name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 2), align: 'right', sortable: true },
{ name: 'sales_index_90d', label: 'Endeks', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true }, { name: 'sales_index_90d', label: 'Endeks', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
{ name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 2), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true }, { name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true }, { name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
@@ -2399,7 +2425,6 @@ const metricLabelOverrides = {
gross_margin_cost_365d: '360G Çıplak Marj', gross_margin_cost_365d: '360G Çıplak Marj',
gross_margin_base_total: 'Genel Taban Marj', gross_margin_base_total: 'Genel Taban Marj',
gross_margin_cost_total: 'Genel Çıplak Marj', gross_margin_cost_total: 'Genel Çıplak Marj',
performance_score: 'Skor',
performance_score_90d: '90G Ürün Skor', performance_score_90d: '90G Ürün Skor',
performance_score_180d: '180G Ürün Skor', performance_score_180d: '180G Ürün Skor',
performance_score_365d: '360G Ürün Skor', performance_score_365d: '360G Ürün Skor',
@@ -2412,10 +2437,10 @@ const metricLabelOverrides = {
customer_count_90d: '90G Tekil Müşteri', customer_count_90d: '90G Tekil Müşteri',
market_count_total: 'Genel Tekil Piyasa', market_count_total: 'Genel Tekil Piyasa',
customer_count_total: 'Genel Tekil Müşteri', customer_count_total: 'Genel Tekil Müşteri',
stock_turnover_90d: '90G Stok Devir Hızı', stock_turnover_90d: '90G Yıllık Stok Devir',
stock_turnover_180d: '180G Stok Devir Hızı', stock_turnover_180d: '180G Yıllık Stok Devir',
stock_turnover_365d: '360G Stok Devir Hızı', stock_turnover_365d: '360G Yıllık Stok Devir',
stock_turnover_total: 'Genel Stok Devir Hızı', stock_turnover_total: 'Genel Yıllık Stok Devir',
sales_index_90d: '90G Endeks', sales_index_90d: '90G Endeks',
sales_index_total: 'Genel Endeks', sales_index_total: 'Genel Endeks',
stock_days_90d: '90G Stok Gün', stock_days_90d: '90G Stok Gün',
@@ -2513,7 +2538,6 @@ function ensureColorYakaColumn (targetColumns) {
].forEach(target => ensureColumns(target, customerPeriodScoreColumns)) ].forEach(target => ensureColumns(target, customerPeriodScoreColumns))
;[ ;[
generalColumns,
orderAnalysisColumns, orderAnalysisColumns,
orderGroupColumns, orderGroupColumns,
orderProductCustomerColumns, orderProductCustomerColumns,
@@ -2639,7 +2663,10 @@ const visibleOrderAnalysisColumns = computed(() => {
function performanceCardsForRow (row) { function performanceCardsForRow (row) {
const source = row || {} const source = row || {}
return [ return [
{ key: 'score', label: 'Skor', value: formatNumber(source.performance_score, 2) }, { key: 'score90', label: '90G Skor', value: formatNumber(source.performance_score_90d ?? source.performance_score, 2) },
{ key: 'score180', label: '180G Skor', value: formatNumber(source.performance_score_180d, 2) },
{ key: 'score360', label: '360G Skor', value: formatNumber(source.performance_score_365d, 2) },
{ key: 'scoreTotal', label: 'Genel Skor', value: formatNumber(source.performance_score_total ?? source.performance_score, 2) },
{ key: 'stock', label: 'Stok', value: formatNumber(source.stock_qty, 0) }, { key: 'stock', label: 'Stok', value: formatNumber(source.stock_qty, 0) },
{ key: 'sales90', label: '90G Satış', value: formatNumber(source.sales_qty_90d, 0) }, { key: 'sales90', label: '90G Satış', value: formatNumber(source.sales_qty_90d, 0) },
{ key: 'sales180', label: '180G Satış', value: formatNumber(source.sales_qty_180d, 0) }, { key: 'sales180', label: '180G Satış', value: formatNumber(source.sales_qty_180d, 0) },
@@ -2700,18 +2727,13 @@ const tabGroupLevels = {
{ key: 'market_key', label: 'Piyasa' } { key: 'market_key', label: 'Piyasa' }
], ],
sales_color_yaka_market_customer: [ sales_color_yaka_market_customer: [
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'country', label: 'Ülke' },
{ key: 'market_key', label: 'Piyasa' }, { key: 'market_key', label: 'Piyasa' },
{ key: 'customer_segment', label: 'Segment' },
{ key: 'customer_code', label: 'Müşteri Kodu' }, { key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' } { key: 'customer_name', label: 'Müşteri' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }
], ],
idle: [ idle: [
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' }, { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
@@ -3052,7 +3074,7 @@ function filterSourceRowsForTab (tabKey, fallbackRows) {
} }
function backendGroupedFilterOptionParams (tabKey) { function backendGroupedFilterOptionParams (tabKey) {
const columns = columnsForTableKey(tabKey) const columns = filterColumnsForTableKey(tabKey)
const fields = columns const fields = columns
.map(col => col?.name || '') .map(col => col?.name || '')
.filter(name => backendGroupedFilterFields.has(name)) .filter(name => backendGroupedFilterFields.has(name))
@@ -3132,7 +3154,7 @@ function backendFilterOptionLabel (name, value) {
function backendGroupedFilterState (tabKey) { function backendGroupedFilterState (tabKey) {
const out = {} const out = {}
const columns = columnsForTableKey(tabKey) const columns = filterColumnsForTableKey(tabKey)
for (const col of columns) { for (const col of columns) {
const name = col?.name || '' const name = col?.name || ''
if (!isColumnFilterable(name)) continue if (!isColumnFilterable(name)) continue
@@ -3152,7 +3174,7 @@ function shouldUseBackendGroupedRows (tabKey) {
function tableFilterSource (tableKey) { function tableFilterSource (tableKey) {
if (backendGroupedSupportedTab(tableKey)) { if (backendGroupedSupportedTab(tableKey)) {
return { rows: backendGroupedRows.value[tableKey] || [], columns: columnsForTableKey(tableKey) } return { rows: backendGroupedRows.value[tableKey] || [], columns: filterColumnsForTableKey(tableKey) }
} }
if (tableKey === 'general') return { rows: generalRows.value, columns: generalColumns } if (tableKey === 'general') return { rows: generalRows.value, columns: generalColumns }
if (tableKey === 'markets') return { rows: marketRows.value, columns: marketColumns } if (tableKey === 'markets') return { rows: marketRows.value, columns: marketColumns }
@@ -3162,6 +3184,20 @@ function tableFilterSource (tableKey) {
} }
function columnsForTableKey (tabKey) { 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 === 'products') return productColumns.value
if (tabKey === 'product_detail') return productDetailColumns.value if (tabKey === 'product_detail') return productDetailColumns.value
if (tabKey === 'general') return generalColumns if (tabKey === 'general') return generalColumns
@@ -3244,6 +3280,9 @@ function compareGroupedBucketsForTable (tableKey, left, right, groupDef) {
function groupedBucketSortValue (sourceRows, sortBy) { function groupedBucketSortValue (sourceRows, sortBy) {
if (!sourceRows?.length) return 0 if (!sourceRows?.length) return 0
if (shouldUseDerivedGroupSortValue(sortBy)) {
return sortValueForColumnName(aggregateGroupFields(sourceRows), sortBy)
}
if (sortBy === 'stock_qty') return distinctVariantStockQty(sourceRows) if (sortBy === 'stock_qty') return distinctVariantStockQty(sourceRows)
if (sortBy === 'idle_cost_usd') return distinctVariantStockCost(sourceRows) if (sortBy === 'idle_cost_usd') return distinctVariantStockCost(sourceRows)
if (sortBy === 'base_price_usd' || sortBy === 'cost_price_usd') return weightedAverageProductCost(sourceRows, sortBy) if (sortBy === 'base_price_usd' || sortBy === 'cost_price_usd') return weightedAverageProductCost(sourceRows, sortBy)
@@ -3252,6 +3291,10 @@ function groupedBucketSortValue (sourceRows, sortBy) {
return sortValueForColumnName(sourceRows[0], sortBy) return sortValueForColumnName(sourceRows[0], sortBy)
} }
function shouldUseDerivedGroupSortValue (sortBy) {
return /^(performance_score|stock_turnover|stock_days|avg_price_usd|unit_profit|gross_profit|gross_margin|market_count|customer_count|sales_index)/i.test(String(sortBy || ''))
}
function sortProductGroupedTableRows (tableRows, sortBy, descending) { function sortProductGroupedTableRows (tableRows, sortBy, descending) {
const source = Array.isArray(tableRows) ? tableRows : [] const source = Array.isArray(tableRows) ? tableRows : []
if (backendGroupedSupportedTab(activeTab.value)) return source if (backendGroupedSupportedTab(activeTab.value)) return source
@@ -3438,8 +3481,8 @@ function aggregateGroupFields (sourceRows, groupField = '') {
for (const field of fields) { for (const field of fields) {
if (field === 'row_key' || field === 'key') continue if (field === 'row_key' || field === 'key') continue
if (/^gross_margin(_base|_cost)?(_|$)/i.test(field)) continue if (/^gross_margin(_base|_cost)?(_|$)/i.test(field)) continue
if (field === 'stock_qty') { if (field === 'stock_qty' || /^avg_stock_/i.test(field)) {
out[field] = distinctVariantStockQty(sourceRows) out[field] = distinctVariantNumber(sourceRows, field)
continue continue
} }
if (field === 'idle_cost_usd') { if (field === 'idle_cost_usd') {
@@ -3466,15 +3509,18 @@ function aggregateGroupFields (sourceRows, groupField = '') {
} }
function shouldSumField (field) { function shouldSumField (field) {
return /(_qty|_usd|_count|_value_usd|line_count|invoice_count|order_count|product_count|market_count|customer_count|overdue_qty|stock_qty|net_stock_after_order|idle_cost_usd)$/i.test(field) return /^(sales_qty_|sales_usd_|avg_daily_sales_|gross_profit_|invoice_count_|market_count_|customer_count_|product_group_count_|product_count_)/i.test(field) ||
/(_qty|_usd|_count|_value_usd|line_count|invoice_count|order_count|product_count|market_count|customer_count|overdue_qty|stock_qty|net_stock_after_order|idle_cost_usd)$/i.test(field)
} }
function shouldAverageField (field) { function shouldAverageField (field) {
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) || return /^(avg_|unit_|base_price|cost_price|gross_margin|expected_margin|sales_index|performance_score|customer_score|stock_days|stock_turnover)/i.test(field) ||
/(_price_usd|_margin|_index|_days)$/i.test(field) /(_price_usd|_margin|_index|_days)$/i.test(field)
} }
function weightFieldForMetric (field) { function weightFieldForMetric (field) {
if (field.startsWith('stock_turnover')) return `sales_qty_${countPeriodSuffix(field)}`
if (field.includes('_180d')) return 'sales_qty_180d' if (field.includes('_180d')) return 'sales_qty_180d'
if (field.includes('_365d')) return 'sales_qty_365d' if (field.includes('_365d')) return 'sales_qty_365d'
if (field.includes('_total')) return 'sales_qty_total' if (field.includes('_total')) return 'sales_qty_total'
@@ -3485,13 +3531,43 @@ function weightFieldForMetric (field) {
function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { function applyDerivedGroupMetrics (out, sourceRows, groupField = '') {
Object.assign(out, normalizeProductCostFields(out)) Object.assign(out, normalizeProductCostFields(out))
const isGroup = Boolean(String(groupField || '').trim())
const preservedCustomerScores = {}
const preservedProductScores = {}
const preservedStockTurnovers = {}
for (const suffix of ['90d', '180d', '365d', 'total']) {
if (Object.prototype.hasOwnProperty.call(out, `customer_score_${suffix}`)) {
preservedCustomerScores[suffix] = Number(out[`customer_score_${suffix}`] || 0)
}
if (Object.prototype.hasOwnProperty.call(out, `performance_score_${suffix}`)) {
preservedProductScores[suffix] = Number(out[`performance_score_${suffix}`] || 0)
}
if (isGroup && Object.prototype.hasOwnProperty.call(out, `stock_turnover_${suffix}`)) {
preservedStockTurnovers[suffix] = Number(out[`stock_turnover_${suffix}`] || 0)
}
}
if (
Object.prototype.hasOwnProperty.call(out, 'performance_score') &&
!Object.prototype.hasOwnProperty.call(preservedProductScores, '90d')
) {
preservedProductScores['90d'] = Number(out.performance_score || 0)
}
for (const suffix of ['90d', '180d', '365d', 'total']) { for (const suffix of ['90d', '180d', '365d', 'total']) {
const sales = Number(out[`sales_usd_${suffix}`] || 0) const sales = Number(out[`sales_usd_${suffix}`] || 0)
const qty = Number(out[`sales_qty_${suffix}`] || 0) const qty = Number(out[`sales_qty_${suffix}`] || 0)
const stockQty = Number(out.stock_qty || 0) const stockQty = Number(out.stock_qty || 0)
const avgStock = Number(out[`avg_stock_${suffix}`] || 0) const avgStock = Number(out[`avg_stock_${suffix}`] || 0)
const turnoverBase = avgStock > 0 ? avgStock : stockQty const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty)
out[`stock_turnover_${suffix}`] = turnoverBase > 0 ? qty / turnoverBase : 0 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 (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty
const { costPrice, basePrice } = periodCostPair(out, suffix) const { costPrice, basePrice } = periodCostPair(out, suffix)
out[`base_price_usd_${suffix}`] = basePrice out[`base_price_usd_${suffix}`] = basePrice
@@ -3530,19 +3606,33 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') {
if (sourceRows.some(row => row.performance_bucket)) { if (sourceRows.some(row => row.performance_bucket)) {
out.performance_bucket = dominantValue(sourceRows, 'performance_bucket') out.performance_bucket = dominantValue(sourceRows, 'performance_bucket')
} }
if (!Object.prototype.hasOwnProperty.call(out, 'customer_score_90d')) out.customer_score_90d = customerSalesPeriodScore(periodMetricSource(out, '90d')) for (const suffix of ['90d', '180d', '365d', 'total']) {
if (!Object.prototype.hasOwnProperty.call(out, 'customer_score_180d')) out.customer_score_180d = customerSalesPeriodScore(periodMetricSource(out, '180d')) out[`customer_score_${suffix}`] = Object.prototype.hasOwnProperty.call(preservedCustomerScores, suffix)
if (!Object.prototype.hasOwnProperty.call(out, 'customer_score_365d')) out.customer_score_365d = customerSalesPeriodScore(periodMetricSource(out, '365d')) ? preservedCustomerScores[suffix]
if (!Object.prototype.hasOwnProperty.call(out, 'customer_score_total')) out.customer_score_total = customerSalesPeriodScore(periodMetricSource(out, 'total')) : customerSalesPeriodScore(periodMetricSource(out, suffix))
if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_90d')) out.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(out, '90d')) out[`performance_score_${suffix}`] = Object.prototype.hasOwnProperty.call(preservedProductScores, suffix)
if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_180d')) out.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(out, '180d')) ? preservedProductScores[suffix]
if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_365d')) out.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(out, '365d')) : productSalesPeriodScore(productPeriodMetricSource(out, suffix))
if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_total')) out.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(out, 'total'))
if (!Object.prototype.hasOwnProperty.call(out, 'performance_score')) {
out.performance_score = Object.prototype.hasOwnProperty.call(out, 'performance_score_90d')
? Number(out.performance_score_90d || 0)
: groupPerformanceScore(out, groupField)
} }
out.performance_score = Number(out.performance_score_90d || 0)
}
function productPerformancePeriodDays (row, suffix) {
if (suffix === '90d') return 90
if (suffix === '180d') return 180
if (suffix === '365d') return 360
if (suffix !== 'total') return 0
const start = parseProductPerformanceDate(row?.period_start) || new Date(Date.UTC(2022, 0, 1))
const end = parseProductPerformanceDate(row?.period_end) || parseProductPerformanceDate(row?.kpi_date)
if (!start || !end || end < start) return 0
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
}
function parseProductPerformanceDate (value) {
const text = String(value || '').trim().slice(0, 10)
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return null
const [year, month, day] = text.split('-').map(Number)
return new Date(Date.UTC(year, month - 1, day))
} }
function groupPerformanceScore (row, groupField = '') { function groupPerformanceScore (row, groupField = '') {
@@ -3557,8 +3647,10 @@ function salesGroupPerformanceScore (row) {
function productSalesPeriodScore (row) { function productSalesPeriodScore (row) {
const suffix = row?.suffix || '90d' const suffix = row?.suffix || '90d'
const periodDays = productPerformancePeriodDays(row, suffix)
return productScore100({ return productScore100({
suffix, suffix,
periodDays,
salesUSD: Number(row?.sales_usd_90d || 0), salesUSD: Number(row?.sales_usd_90d || 0),
salesIndex: Number(row?.sales_index_90d || 0), salesIndex: Number(row?.sales_index_90d || 0),
margin: Number(row?.gross_margin_cost_90d ?? row?.gross_margin_90d ?? 0), margin: Number(row?.gross_margin_cost_90d ?? row?.gross_margin_90d ?? 0),
@@ -3572,6 +3664,9 @@ function productPeriodMetricSource (row, suffix) {
const salesIndex = Number(row?.[`sales_index_${suffix}`] || 0) const salesIndex = Number(row?.[`sales_index_${suffix}`] || 0)
const qty = Number(row?.[`sales_qty_${suffix}`] || 0) const qty = Number(row?.[`sales_qty_${suffix}`] || 0)
const stockQty = Number(row?.stock_qty || 0) const stockQty = Number(row?.stock_qty || 0)
const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0)
const isGroup = Boolean(row?.__group || String(row?.group_field || '').trim())
const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty)
const turnoverKey = `stock_turnover_${suffix}` const turnoverKey = `stock_turnover_${suffix}`
const hasTurnover = Object.prototype.hasOwnProperty.call(row || {}, turnoverKey) const hasTurnover = Object.prototype.hasOwnProperty.call(row || {}, turnoverKey)
return { return {
@@ -3581,7 +3676,7 @@ function productPeriodMetricSource (row, suffix) {
gross_margin_cost_90d: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0), gross_margin_cost_90d: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
gross_margin_90d: Number(row?.[`gross_margin_${suffix}`] ?? 0), gross_margin_90d: Number(row?.[`gross_margin_${suffix}`] ?? 0),
sales_qty_90d: qty, sales_qty_90d: qty,
stock_turnover_90d: hasTurnover ? Number(row?.[turnoverKey] || 0) : (stockQty > 0 ? qty / stockQty : 0), stock_turnover_90d: hasTurnover ? Number(row?.[turnoverKey] || 0) : annualizedStockTurnover(qty, turnoverBase, productPerformancePeriodDays(row, suffix)),
market_count_90d: periodCount(row, 'market_count', suffix), market_count_90d: periodCount(row, 'market_count', suffix),
customer_count_90d: periodCount(row, 'customer_count', suffix), customer_count_90d: periodCount(row, 'customer_count', suffix),
stock_qty: stockQty stock_qty: stockQty
@@ -3623,15 +3718,15 @@ function periodMetricSource (row, suffix) {
} }
} }
function productScore100 ({ suffix = '90d', salesUSD = 0, margin = 0, stockTurnover = 0, marketCount = 0, customerCount = 0 } = {}) { function productScore100 ({ suffix = '90d', periodDays = 0, salesUSD = 0, margin = 0, stockTurnover = 0, marketCount = 0, customerCount = 0 } = {}) {
if (Number(salesUSD || 0) <= 0) return 1 if (Number(salesUSD || 0) <= 0) return 1
const revenue = ratioScore(salesUSD, productRevenueTarget(suffix)) const revenue = ratioScore(salesUSD, productRevenueTarget(suffix, periodDays))
return clampScore( return clampScore(
0.30 * marginScore(margin) + 0.30 * marginScore(margin) +
0.20 * ratioScore(stockTurnover, 1.5) + 0.20 * ratioScore(stockTurnover, stockTurnoverTarget(suffix)) +
0.20 * revenue + 0.20 * revenue +
0.15 * ratioScore(marketCount, 8) + 0.05 * ratioScore(marketCount, marketSpreadTarget(suffix)) +
0.15 * ratioScore(customerCount, 25) 0.25 * ratioScore(customerCount, customerSpreadTarget(suffix, periodDays))
) )
} }
@@ -3653,7 +3748,7 @@ function ratioScore (value, target) {
} }
function marginScore (margin) { function marginScore (margin) {
return ratioScore(Math.max(0, Number(margin || 0)), 0.40) return ratioScore(Math.max(0, Number(margin || 0)), 0.45)
} }
function clampScore (score) { function clampScore (score) {
@@ -3662,11 +3757,11 @@ function clampScore (score) {
return Math.round(Math.min(100, n) * 10000) / 10000 return Math.round(Math.min(100, n) * 10000) / 10000
} }
function productRevenueTarget (suffix) { function productRevenueTarget (suffix, periodDays = 0) {
if (suffix === '180d') return 20000 if (suffix === '180d') return 50000
if (suffix === '365d') return 40000 if (suffix === '365d') return 100000
if (suffix === 'total') return 120000 if (suffix === 'total') return 50000 * totalPeriodMultiplier(periodDays)
return 10000 return 25000
} }
function customerRevenueTarget (suffix) { function customerRevenueTarget (suffix) {
@@ -3683,6 +3778,43 @@ function customerQtyTarget (suffix) {
return 500 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) { function periodCount (row, prefix, suffix) {
const keyed = Number(row?.[`${prefix}_${suffix}`] || 0) const keyed = Number(row?.[`${prefix}_${suffix}`] || 0)
if (keyed > 0) return keyed if (keyed > 0) return keyed
@@ -3716,19 +3848,23 @@ function productVariantStockKey (row) {
} }
function distinctVariantStockQty (sourceRows) { function distinctVariantStockQty (sourceRows) {
return distinctVariantNumber(sourceRows, 'stock_qty')
}
function distinctVariantNumber (sourceRows, field) {
const seen = new Set() const seen = new Set()
let total = 0 let total = 0
let keyed = false let keyed = false
for (const row of sourceRows) { for (const row of sourceRows) {
const key = productVariantStockKey(row) const key = productVariantStockKey(row)
const stockQty = Number(row?.stock_qty || 0) const value = Number(row?.[field] || 0)
if (!key) continue if (!key) continue
keyed = true keyed = true
if (seen.has(key)) continue if (seen.has(key)) continue
seen.add(key) seen.add(key)
total += stockQty total += value
} }
return keyed ? total : sumRows(sourceRows, 'stock_qty') return keyed ? total : sumRows(sourceRows, field)
} }
function distinctVariantStockCost (sourceRows) { function distinctVariantStockCost (sourceRows) {
@@ -3784,6 +3920,12 @@ function distinctRevenueMarketCount (sourceRows, suffix, fallbackField) {
const values = new Set() const values = new Set()
for (const row of sourceRows) { for (const row of sourceRows) {
if (Number(row?.[salesField] || 0) <= 0) continue 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) const value = displayMarketName(row?.market_key)
if (value && value !== 'STOK' && value !== '-') values.add(value) if (value && value !== 'STOK' && value !== '-') values.add(value)
} }
@@ -3798,6 +3940,11 @@ function distinctRevenueCustomerCount (sourceRows, suffix, fallbackField) {
const values = new Set() const values = new Set()
for (const row of sourceRows) { for (const row of sourceRows) {
if (Number(row?.[salesField] || 0) <= 0) continue 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() const code = String(row?.customer_code || '').trim()
if (code && code !== '-') values.add(code) if (code && code !== '-') values.add(code)
} }
@@ -3807,6 +3954,22 @@ function distinctRevenueCustomerCount (sourceRows, suffix, fallbackField) {
}, 0) }, 0)
} }
function stringListFromRow (row, field) {
const value = row?.[field]
if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean)
const text = String(value || '').trim()
if (!text) return []
if (text.startsWith('[')) {
try {
const parsed = JSON.parse(text)
return Array.isArray(parsed) ? parsed.map(item => String(item || '').trim()).filter(Boolean) : []
} catch {
return []
}
}
return [text]
}
function distinctSummary (sourceRows, field, fallbackField) { function distinctSummary (sourceRows, field, fallbackField) {
const values = new Set() const values = new Set()
for (const row of sourceRows) { for (const row of sourceRows) {
@@ -3879,14 +4042,9 @@ function scoreSuffixForMetric (field) {
} }
function scoreWeight (row, suffix) { function scoreWeight (row, suffix) {
const salesUsd = Number(row?.[`sales_usd_${suffix}`] || 0)
if (salesUsd > 0) return salesUsd
const salesQty = Number(row?.[`sales_qty_${suffix}`] || 0) const salesQty = Number(row?.[`sales_qty_${suffix}`] || 0)
if (salesQty > 0) return salesQty if (salesQty > 0) return salesQty
const stockQty = Number(row?.stock_qty || 0) return 0
if (stockQty <= 0) return 1
const costPrice = Number(row?.[`cost_price_usd_${suffix}`] || row?.cost_price_usd || 0)
return costPrice > 0 ? stockQty * costPrice : stockQty
} }
function weightedAverageScore (sourceRows, valueField, suffix) { function weightedAverageScore (sourceRows, valueField, suffix) {
@@ -4126,10 +4284,10 @@ function withProductMargins (row) {
const out = { const out = {
...next, ...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, 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: existingOrStockTurnover(next?.stock_turnover_90d, next?.sales_qty_90d, next?.stock_qty), stock_turnover_90d: groupedOrExistingStockTurnover(next, '90d', 90),
stock_turnover_180d: existingOrStockTurnover(next?.stock_turnover_180d, next?.sales_qty_180d, next?.stock_qty), stock_turnover_180d: groupedOrExistingStockTurnover(next, '180d', 180),
stock_turnover_365d: existingOrStockTurnover(next?.stock_turnover_365d, next?.sales_qty_365d, next?.stock_qty), stock_turnover_365d: groupedOrExistingStockTurnover(next, '365d', 360),
stock_turnover_total: existingOrStockTurnover(next?.stock_turnover_total, next?.sales_qty_total, next?.stock_qty) stock_turnover_total: groupedOrExistingStockTurnover(next, 'total', productPerformancePeriodDays(next, 'total'))
} }
for (const suffix of ['90d', '180d', '365d', 'total']) { for (const suffix of ['90d', '180d', '365d', 'total']) {
applyPeriodProfitFields(out, suffix) applyPeriodProfitFields(out, suffix)
@@ -4142,7 +4300,7 @@ function withGeneralMargins (row) {
applyPeriodProfitFields(out, 'total') applyPeriodProfitFields(out, 'total')
return { return {
...out, ...out,
stock_turnover_total: existingOrStockTurnover(out?.stock_turnover_total, out?.sales_qty_total, out?.stock_qty) stock_turnover_total: groupedOrExistingStockTurnover(out, 'total', productPerformancePeriodDays(out, 'total'))
} }
} }
@@ -4209,15 +4367,27 @@ function applyPeriodProfitFields (row, suffix) {
row[`gross_margin_cost_${suffix}`] = marginFromSalesCost(salesUSD, qty, costPrice) row[`gross_margin_cost_${suffix}`] = marginFromSalesCost(salesUSD, qty, costPrice)
} }
function stockTurnover (salesQty, stockQty) { function stockTurnover (salesQty, stockQty, periodDays) {
const stock = Number(stockQty || 0) return annualizedStockTurnover(salesQty, stockQty, periodDays)
if (stock <= 0) return 0
return Number(salesQty || 0) / stock
} }
function existingOrStockTurnover (value, salesQty, stockQty) { function existingOrStockTurnover (value, salesQty, stockQty, periodDays) {
const current = Number(value) const current = Number(value)
return value !== undefined && value !== null && Number.isFinite(current) ? current : stockTurnover(salesQty, stockQty) return value !== undefined && value !== null && Number.isFinite(current) ? current : stockTurnover(salesQty, stockQty, periodDays)
}
function groupedOrExistingStockTurnover (row, suffix, periodDays) {
const salesQty = Number(row?.[`sales_qty_${suffix}`] || 0)
const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0)
const stockQty = Number(row?.stock_qty || 0)
if (row?.__group) {
const current = Number(row?.[`stock_turnover_${suffix}`])
if (Number.isFinite(current)) return current
if (avgStock > 0) return stockTurnover(salesQty, avgStock, periodDays)
return 0
}
const stockBase = avgStock > 0 ? avgStock : stockQty
return existingOrStockTurnover(row?.[`stock_turnover_${suffix}`], salesQty, stockBase, periodDays)
} }
function filterKey (tableKey, name) { function filterKey (tableKey, name) {
@@ -5204,7 +5374,7 @@ function productPerformanceExcelExportTableKey () {
function buildProductPerformanceExcelExportFilters () { function buildProductPerformanceExcelExportFilters () {
const tableKey = productPerformanceExcelExportTableKey() const tableKey = productPerformanceExcelExportTableKey()
const out = {} const out = {}
for (const col of columnsForTableKey(tableKey) || []) { for (const col of filterColumnsForTableKey(tableKey) || []) {
const name = String(col?.name || '').trim() const name = String(col?.name || '').trim()
if (!productPerformanceExcelExportFilterFields.has(name)) continue if (!productPerformanceExcelExportFilterFields.has(name)) continue
const selected = selectedColumnFilters(tableKey, name) const selected = selectedColumnFilters(tableKey, name)
@@ -5986,95 +6156,95 @@ onBeforeUnmount(() => {
table-layout: fixed; table-layout: fixed;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(-n+10)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(-n+10)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(-n+10)) {
position: sticky; position: sticky;
z-index: 2; z-index: 2;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(-n+10)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)) {
z-index: 30; z-index: 30;
background: #f8fbff; background: #f8fbff;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) {
background: #fff; background: #fff;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(1)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(1)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(1)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(1)) {
left: 0; left: 0;
width: 190px; width: 190px;
min-width: 190px; min-width: 190px;
max-width: 190px; max-width: 190px;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(2)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(2)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(2)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(2)) {
left: 190px; left: 190px;
width: 140px; width: 140px;
min-width: 140px; min-width: 140px;
max-width: 140px; max-width: 140px;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(3)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(3)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(3)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(3)) {
left: 330px; left: 330px;
width: 110px; width: 110px;
min-width: 110px; min-width: 110px;
max-width: 110px; max-width: 110px;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(4)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(4)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(4)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(4)) {
left: 440px; left: 440px;
width: 150px; width: 150px;
min-width: 150px; min-width: 150px;
max-width: 150px; max-width: 150px;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(5)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(5)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(5)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(5)) {
left: 590px; left: 590px;
width: 170px; width: 170px;
min-width: 170px; min-width: 170px;
max-width: 170px; max-width: 170px;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(6)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(6)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(6)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(6)) {
left: 760px; left: 760px;
width: 150px; width: 150px;
min-width: 150px; min-width: 150px;
max-width: 150px; max-width: 150px;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(7)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(7)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(7)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(7)) {
left: 910px; left: 910px;
width: 130px; width: 130px;
min-width: 130px; min-width: 130px;
max-width: 130px; max-width: 130px;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(8)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(8)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(8)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(8)) {
left: 1040px; left: 1040px;
width: 90px; width: 90px;
min-width: 90px; min-width: 90px;
max-width: 90px; max-width: 90px;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(9)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(9)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(9)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(9)) {
left: 1130px; left: 1130px;
width: 80px; width: 80px;
min-width: 80px; min-width: 80px;
max-width: 80px; max-width: 80px;
} }
.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(10)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(10)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(10)) { .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(10)) {
left: 1210px; left: 1210px;
width: 130px; width: 130px;
min-width: 130px; min-width: 130px;
@@ -6082,6 +6252,58 @@ onBeforeUnmount(() => {
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
} }
.product-breakdown-table.compact-detail-columns :deep(.q-table) {
min-width: 2450px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(-n+4)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(-n+4)) {
position: sticky;
z-index: 2;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(-n+4)) {
z-index: 30;
background: #f8fbff;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+4)) {
background: #fff;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(1)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(1)) {
left: 0;
width: 86px;
min-width: 86px;
max-width: 86px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(2)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(2)) {
left: 86px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(3)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(3)) {
left: 236px;
width: 112px;
min-width: 112px;
max-width: 112px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(4)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(4)) {
left: 348px;
width: 132px;
min-width: 132px;
max-width: 132px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.product-breakdown-table.product-detail-table :deep(.q-table) { .product-breakdown-table.product-detail-table :deep(.q-table) {
min-width: 2620px; min-width: 2620px;
} }
@@ -6198,10 +6420,14 @@ onBeforeUnmount(() => {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.product-breakdown-table:not(.product-detail-table) :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 th:nth-child(n+11):not(.text-right)),
.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(n+11):not(.text-right)), .product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(n+11):not(.text-right)),
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(n+5):not(.text-right)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(n+5):not(.text-right)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(n+4):not(.text-right)), .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(n+4):not(.text-right)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(n+4):not(.text-right)), .sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(n+4):not(.text-right)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(n+5):not(.text-right)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(n+5):not(.text-right)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(n+6):not(.text-right)), .sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(n+6):not(.text-right)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(n+6):not(.text-right)), .sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(n+6):not(.text-right)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(n+7):not(.text-right)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(n+7):not(.text-right)),
@@ -6281,6 +6507,8 @@ onBeforeUnmount(() => {
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)), .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(-n+3)), .sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(-n+3)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(-n+4)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(-n+4)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)), .sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(-n+5)), .sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(-n+5)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)),
@@ -6300,6 +6528,7 @@ onBeforeUnmount(() => {
} }
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)), .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(-n+4)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)), .sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)),
.sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)), .sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)),
@@ -6317,6 +6546,8 @@ onBeforeUnmount(() => {
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(3)), .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(3)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(3)), .sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(3)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(4)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(4)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(5)), .sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(5)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(5)), .sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(5)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(6)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(6)),
+9 -25
View File
@@ -423,7 +423,6 @@ const productImageListByCode = ref({})
const productImageListLoading = ref({}) const productImageListLoading = ref({})
const productImageFallbackByKey = ref({}) const productImageFallbackByKey = ref({})
const productImageContentLoading = ref({}) const productImageContentLoading = ref({})
const productImageBlobUrls = ref([])
const productImageListBlockedUntil = ref(0) const productImageListBlockedUntil = ref(0)
const productCardDialog = ref(false) const productCardDialog = ref(false)
const productCardData = ref({}) const productCardData = ref({})
@@ -702,24 +701,13 @@ function clearGalleryQueryIndex() {
async function resolveProductImageUrlForCarousel(item) { async function resolveProductImageUrlForCarousel(item) {
const resolved = resolveProductImageUrl(item) const resolved = resolveProductImageUrl(item)
const contentUrl = String(resolved.contentUrl || '').trim()
if (contentUrl) return contentUrl
const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim() const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim()
if (directUrl) return directUrl if (directUrl) return directUrl
const contentUrl = String(resolved.contentUrl || '').trim() return ''
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
}
}
return contentUrl
} }
function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') { function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
@@ -769,9 +757,9 @@ async function flushProductImageBatch() {
const list = Array.isArray(item.images) ? item.images : [] const list = Array.isArray(item.images) ? item.images : []
const first = list[0] || null const first = list[0] || null
const resolved = resolveProductImageUrl(first) const resolved = resolveProductImageUrl(first)
const url = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || '' const url = resolved.contentUrl || resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
productImageCache.value[key] = String(url || '').trim() productImageCache.value[key] = String(url || '').trim()
productImageFallbackByKey.value[key] = resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || '' productImageFallbackByKey.value[key] = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
if (queued) productImageListByCode.value[queued.listKey] = list if (queued) productImageListByCode.value[queued.listKey] = list
delete productImageLoading.value[key] delete productImageLoading.value[key]
} }
@@ -860,11 +848,11 @@ async function ensureProductImage(code, color, secondColor = '', dim1Id = '', di
productImageCache.value[key] = String( productImageCache.value[key] = String(
preferredCardUrl || preferredCardUrl ||
primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || primaryResolved.contentUrl || primaryResolved.contentUrl || primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl ||
secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || secondaryResolved.contentUrl || secondaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl ||
'' ''
).trim() ).trim()
productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.contentUrl || '' productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || ''
} catch (err) { } catch (err) {
console.warn('[ProductStockByAttributes] product image fetch failed', { code, color, err }) console.warn('[ProductStockByAttributes] product image fetch failed', { code, color, err })
productImageCache.value[key] = '' productImageCache.value[key] = ''
@@ -1576,10 +1564,6 @@ onUnmounted(() => {
clearTimeout(filterOptionsDebounceTimer) clearTimeout(filterOptionsDebounceTimer)
filterOptionsDebounceTimer = null filterOptionsDebounceTimer = null
} }
for (const url of productImageBlobUrls.value) {
try { URL.revokeObjectURL(url) } catch {}
}
productImageBlobUrls.value = []
}) })
</script> </script>
+9 -25
View File
@@ -390,7 +390,6 @@ const productImageListByCode = ref({})
const productImageListLoading = ref({}) const productImageListLoading = ref({})
const productImageFallbackByKey = ref({}) const productImageFallbackByKey = ref({})
const productImageContentLoading = ref({}) const productImageContentLoading = ref({})
const productImageBlobUrls = ref([])
const productImageListBlockedUntil = ref(0) const productImageListBlockedUntil = ref(0)
const productCardDialog = ref(false) const productCardDialog = ref(false)
const productCardData = ref({}) const productCardData = ref({})
@@ -681,24 +680,13 @@ function clearGalleryQueryIndex() {
async function resolveProductImageUrlForCarousel(item) { async function resolveProductImageUrlForCarousel(item) {
const resolved = resolveProductImageUrl(item) const resolved = resolveProductImageUrl(item)
const contentUrl = String(resolved.contentUrl || '').trim()
if (contentUrl) return contentUrl
const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim() const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim()
if (directUrl) return directUrl if (directUrl) return directUrl
const contentUrl = String(resolved.contentUrl || '').trim() return ''
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
}
}
return contentUrl
} }
function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') { function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
@@ -748,9 +736,9 @@ async function flushProductImageBatch() {
const list = Array.isArray(item.images) ? item.images : [] const list = Array.isArray(item.images) ? item.images : []
const first = list[0] || null const first = list[0] || null
const resolved = resolveProductImageUrl(first) const resolved = resolveProductImageUrl(first)
const url = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || '' const url = resolved.contentUrl || resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
productImageCache.value[key] = String(url || '').trim() productImageCache.value[key] = String(url || '').trim()
productImageFallbackByKey.value[key] = resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || '' productImageFallbackByKey.value[key] = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || ''
if (queued) productImageListByCode.value[queued.listKey] = list if (queued) productImageListByCode.value[queued.listKey] = list
delete productImageLoading.value[key] delete productImageLoading.value[key]
} }
@@ -836,11 +824,11 @@ async function ensureProductImage(code, color, secondColor = '', dim1Id = '', di
const secondaryResolved = resolveProductImageUrl(secondaryItem) const secondaryResolved = resolveProductImageUrl(secondaryItem)
productImageCache.value[key] = String( productImageCache.value[key] = String(
preferredCardUrl || preferredCardUrl ||
primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || primaryResolved.contentUrl || primaryResolved.contentUrl || primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl ||
secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || secondaryResolved.contentUrl || secondaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl ||
'' ''
).trim() ).trim()
productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.contentUrl || '' productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || ''
} catch (err) { } catch (err) {
console.warn('[ProductStockQuery] product image fetch failed', { code, color, err }) console.warn('[ProductStockQuery] product image fetch failed', { code, color, err })
productImageCache.value[key] = '' productImageCache.value[key] = ''
@@ -1374,10 +1362,6 @@ function resetForm() {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('mousemove', onFullscreenMouseMove) window.removeEventListener('mousemove', onFullscreenMouseMove)
window.removeEventListener('mouseup', onFullscreenMouseUp) window.removeEventListener('mouseup', onFullscreenMouseUp)
for (const url of productImageBlobUrls.value) {
try { URL.revokeObjectURL(url) } catch {}
}
productImageBlobUrls.value = []
}) })
onMounted(async () => { onMounted(async () => {
@@ -40,6 +40,14 @@
:loading="loading" :loading="loading"
@click="fetchRows" @click="fetchRows"
/> />
<q-btn
label="Excel'e Aktar"
icon="download"
color="primary"
outline
:disable="loading || rows.length === 0"
@click="exportVisibleRows"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -141,7 +149,7 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { usePermission } from 'src/composables/usePermission' import { usePermission } from 'src/composables/usePermission'
import { get, extractApiErrorDetail } from 'src/services/api' import { get, extractApiErrorDetail } from 'src/services/api'
@@ -168,6 +176,10 @@ const dateColumns = new Set(['Tarihi', 'dteKayitTarihi', 'dteGuncellemeTarihi',
const columns = [ const columns = [
{ name: 'open', label: '', field: 'open', align: 'center', sortable: false, style: 'width:3%', headerStyle: 'width:3%' }, { name: 'open', label: '', field: 'open', align: 'center', sortable: false, style: 'width:3%', headerStyle: 'width:3%' },
{ name: 'UretimSekli', label: 'Uretim Sekli', field: 'UretimSekli', align: 'left', sortable: true, style: 'width:9%', headerStyle: 'width:9%' }, { name: 'UretimSekli', label: 'Uretim Sekli', field: 'UretimSekli', align: 'left', sortable: true, style: 'width:9%', headerStyle: 'width:9%' },
{ name: 'nUrtSiparisNo', label: 'Uretim Siparis No', field: 'nUrtSiparisNo', align: 'left', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'ceketDepoGiris', label: 'C-Ceket Depo Giris', field: 'ceketDepoGiris', align: 'right', sortable: true, format: val => formatMoney(val), style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'pantolonDepoGiris', label: 'P-Pantolon Depo Giris', field: 'pantolonDepoGiris', align: 'right', sortable: true, format: val => formatMoney(val), style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'yelekDepoGiris', label: 'Y-Yelek Depo Giris', field: 'yelekDepoGiris', align: 'right', sortable: true, format: val => formatMoney(val), style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'nOnMLNo', label: 'nOnMLNo', field: 'nOnMLNo', align: 'left', sortable: true, style: 'width:6%', headerStyle: 'width:6%' }, { name: 'nOnMLNo', label: 'nOnMLNo', field: 'nOnMLNo', align: 'left', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'UrunKodu', label: 'UrunKodu', field: 'UrunKodu', align: 'left', sortable: true, style: 'width:7%', headerStyle: 'width:7%' }, { name: 'UrunKodu', label: 'UrunKodu', field: 'UrunKodu', align: 'left', sortable: true, style: 'width:7%', headerStyle: 'width:7%' },
{ name: 'UrunAdi', label: 'UrunAdi', field: 'UrunAdi', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' }, { name: 'UrunAdi', label: 'UrunAdi', field: 'UrunAdi', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
@@ -186,6 +198,30 @@ const columns = [
] ]
const columnFilters = reactive({}) const columnFilters = reactive({})
const LIST_STATE_STORAGE_KEY = 'bssapp:production-product-costing:has-cost-list-state:v1'
function persistListState () {
try {
sessionStorage.setItem(LIST_STATE_STORAGE_KEY, JSON.stringify({
filters: { search: String(filters.search || '') },
columnFilters: JSON.parse(JSON.stringify(columnFilters)),
pagination: { ...tablePagination.value }
}))
} catch {}
}
function restoreListState () {
try {
const raw = sessionStorage.getItem(LIST_STATE_STORAGE_KEY)
if (!raw) return
const saved = JSON.parse(raw)
filters.search = String(saved?.filters?.search || '')
Object.assign(columnFilters, saved?.columnFilters && typeof saved.columnFilters === 'object' ? saved.columnFilters : {})
if (saved?.pagination && typeof saved.pagination === 'object') {
tablePagination.value = { ...tablePagination.value, ...saved.pagination }
}
} catch {}
}
function getColumnFilter (name) { function getColumnFilter (name) {
if (!columnFilters[name]) { if (!columnFilters[name]) {
@@ -293,9 +329,11 @@ function clearAllColumnFilters () {
} }
let searchTimer = null let searchTimer = null
let filterWatchEnabled = false
watch( watch(
() => filters.search, () => filters.search,
() => { () => {
if (!filterWatchEnabled) return
clearTimeout(searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => {
fetchRows() fetchRows()
@@ -348,19 +386,66 @@ function clearFilters () {
fetchRows() fetchRows()
} }
function escapeExcelCsvCell (value) {
let text = String(value ?? '')
// Prevent spreadsheet applications from evaluating data as a formula.
if (/^[=+\-@]/.test(text)) text = `'${text}`
return `"${text.replace(/"/g, '""')}"`
}
const excelNumericColumns = new Set(['ceketDepoGiris', 'pantolonDepoGiris', 'yelekDepoGiris', 'lTutarTL', 'lTutarUSD', 'lTutarEURO'])
function formatExcelCsvCell (col, rawValue, displayValue) {
if (excelNumericColumns.has(col?.name)) {
const numericValue = Number(rawValue)
if (Number.isFinite(numericValue)) return String(numericValue).replace('.', ',')
}
return escapeExcelCsvCell(displayValue)
}
function exportVisibleRows () {
const visibleRows = Array.isArray(rows.value) ? rows.value : []
if (visibleRows.length === 0) return
const exportColumns = columns.filter(col => col.name !== 'open')
const csvLines = [
exportColumns.map(col => escapeExcelCsvCell(col.label)).join(';'),
...visibleRows.map(row => exportColumns.map(col => {
const rawValue = typeof col.field === 'function' ? col.field(row) : row?.[col.field]
const displayValue = typeof col.format === 'function' ? col.format(rawValue, row) : rawValue
return formatExcelCsvCell(col, rawValue, displayValue)
}).join(';'))
]
const blob = new Blob([`\uFEFF${csvLines.join('\r\n')}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `mevcut_maliyetli_urunler_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
function openRow (row) { function openRow (row) {
const urunKodu = String(row?.UrunKodu || '').trim() const urunKodu = String(row?.UrunKodu || '').trim()
if (!urunKodu) return if (!urunKodu) return
persistListState()
router.push({ router.push({
name: 'production-product-costing-has-cost-history', name: 'production-product-costing-has-cost-history',
query: { urun_kodu: urunKodu } query: { urun_kodu: urunKodu }
}) })
} }
onMounted(() => { onMounted(async () => {
if (!canReadOrder.value) return if (!canReadOrder.value) return
fetchRows() restoreListState()
await nextTick()
filterWatchEnabled = true
await fetchRows()
}) })
</script> </script>
@@ -469,7 +469,7 @@
</span> </span>
</div> </div>
<div class="sub-right pcd-sub-right-clickable" @click="toggleGroup(grp, gi)"> <div class="sub-right pcd-sub-right-clickable" @click="toggleGroup(grp, gi)">
Grup Toplami TRY: {{ formatBarMoney(resolveGroupTRYTutar(grp)) }} | USD: {{ formatBarMoney(resolveGroupUSDTutar(grp)) }} Grup Toplami TRY: {{ formatBarMoney(grp.liveTryTotal) }} | USD: {{ formatBarMoney(grp.liveUsdTotal) }}
<q-icon <q-icon
:name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'" :name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'"
size="18px" size="18px"
@@ -1557,8 +1557,14 @@ function ensureBeforeUnloadGuard (enabled) {
return '' return ''
} }
} }
function shouldIncludeRowInCostingTotal (row) {
// The explicit "Maliyete Dahil" selection is authoritative for the top
// header and the amount persisted to spUrtOnMLMas, including CM rows.
return normalizeBooleanFlag(row?.maliyeteDahil ?? row?.maliyete_dahil ?? row?.Maliyete_dahil)
}
const toolbarSummary = computed(() => flatDetailRows.value.reduce((acc, row) => { const toolbarSummary = computed(() => flatDetailRows.value.reduce((acc, row) => {
if (!row?.maliyeteDahil) return acc if (!shouldIncludeRowInCostingTotal(row)) return acc
acc.tryTotal += resolveRowTRYTutar(row) acc.tryTotal += resolveRowTRYTutar(row)
acc.usdTotal += resolveRowUSDTutar(row) acc.usdTotal += resolveRowUSDTutar(row)
acc.eurTotal += resolveRowEURTutar(row) acc.eurTotal += resolveRowEURTutar(row)
@@ -2821,54 +2827,84 @@ function recalculateAllDetailRows () {
...grp, ...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => recalculateDetailRow({ ...row }, { preserveInputs: true })) items: (Array.isArray(grp?.items) ? grp.items : []).map(row => recalculateDetailRow({ ...row }, { preserveInputs: true }))
})) }))
refreshLiveGroupTotals()
} }
function resolveRowTRYTutar (row) { function resolveRowTRYTutar (row) {
const tryAmount = Number(row?.lTutar || 0) const quantity = resolveNumericRowQuantity(row)
return Number.isFinite(tryAmount) ? tryAmount : 0 const inputPrice = resolveNumericRowInputPrice(row)
const inputCurrency = resolveInputCurrency(row)
const liveAmount = resolveTRYUnitPriceByInput(
inputPrice,
inputCurrency,
resolveExchangeRateValue('USD'),
resolveExchangeRateValue('EUR'),
resolveExchangeRateValue('GBP')
) * quantity
if (Number.isFinite(liveAmount) && inputPrice > 0) return liveAmount
return parseMoneyInput(row?.lTutar)
} }
function resolveRowEURTutar (row) { function resolveRowEURTutar (row) {
const eurAmount = Number(row?.eurTutar || 0) return parseMoneyInput(row?.eurTutar)
return Number.isFinite(eurAmount) ? eurAmount : 0
} }
function resolveRowGBPTutar (row) { function resolveRowGBPTutar (row) {
const gbpAmount = Number(row?.gbpTutar || 0) return parseMoneyInput(row?.gbpTutar)
return Number.isFinite(gbpAmount) ? gbpAmount : 0
} }
function resolveRowUSDTutar (row) { function resolveRowUSDTutar (row) {
const usdAmount = Number(row?.usdTutar || 0) const quantity = resolveNumericRowQuantity(row)
if (Number.isFinite(usdAmount)) return usdAmount const inputPrice = resolveNumericRowInputPrice(row)
const inputCurrency = resolveInputCurrency(row)
const liveAmount = resolveUSDUnitPriceByInput(
inputPrice,
inputCurrency,
resolveExchangeRateValue('USD'),
resolveExchangeRateValue('EUR'),
resolveExchangeRateValue('GBP')
) * quantity
const miktar = resolveNumericRowQuantity(row) if (Number.isFinite(liveAmount) && inputPrice > 0) return liveAmount
const dovizFiyati = Number(row?.lDovizFiyati || 0)
const calc = miktar * dovizFiyati const rawUSDAmount = row?.usdTutar
if (rawUSDAmount !== undefined && rawUSDAmount !== null && String(rawUSDAmount).trim() !== '') {
return parseMoneyInput(rawUSDAmount)
}
const dovizFiyati = parseMoneyInput(row?.lDovizFiyati)
const calc = quantity * dovizFiyati
return Number.isFinite(calc) ? calc : 0 return Number.isFinite(calc) ? calc : 0
} }
function shouldIgnoreGroupMaliyeteDahil (grp) { function shouldIncludeRowInGroupTotal (_grp, row) {
return isCMGroupName(grp?.sAciklama3) // Sub-headers show the gross amount entered in that group. Inclusion in the
// actual costing is shown separately by the page header and checkbox.
return Boolean(row)
} }
function shouldIncludeRowInGroupTotal (grp, row) { function refreshLiveGroupTotals () {
return shouldIgnoreGroupMaliyeteDahil(grp) || normalizeBooleanFlag(row?.maliyeteDahil) detailGroups.value = detailGroups.value.map(group => {
} const totals = (Array.isArray(group?.items) ? group.items : []).reduce((acc, row) => {
if (!shouldIncludeRowInGroupTotal(group, row)) return acc
acc.tryTotal += resolveRowTRYTutar(row)
acc.usdTotal += resolveRowUSDTutar(row)
acc.quantity += resolveNumericRowQuantity(row)
return acc
}, { tryTotal: 0, usdTotal: 0, quantity: 0 })
function resolveGroupTRYTutar (grp) { return {
const items = Array.isArray(grp?.items) ? grp.items : [] ...group,
return items.reduce((acc, row) => acc + (shouldIncludeRowInGroupTotal(grp, row) ? resolveRowTRYTutar(row) : 0), 0) liveTryTotal: totals.tryTotal,
liveUsdTotal: totals.usdTotal,
liveQuantity: totals.quantity
} }
})
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 resolveGroupQuantity (grp) { function resolveGroupQuantity (grp) {
const items = Array.isArray(grp?.items) ? grp.items : [] return parseMoneyInput(grp?.liveQuantity)
return items.reduce((acc, row) => acc + (Number(row?.lMiktar || 0) || 0), 0)
} }
function groupKey (grp, gi) { function groupKey (grp, gi) {
@@ -3244,7 +3280,7 @@ function onRowQuantityInput (row, value) {
} }
function triggerUIUpdate () { function triggerUIUpdate () {
detailGroups.value = [...detailGroups.value] refreshLiveGroupTotals()
} }
function normalizeRowQuantityDisplay (row) { function normalizeRowQuantityDisplay (row) {
@@ -3457,6 +3493,11 @@ async function fetchBulkItemPrices () {
error: String(e?.message || e || '') error: String(e?.message || e || '')
}) })
} }
// Bulk price and previous-cost autofill paths can update many rows at once.
// Recalculate once more as a whole so group sub-header totals react immediately.
recalculateAllDetailRows()
schedulePersistLocalDraft()
triggerUIUpdate()
} catch (err) { } catch (err) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
@@ -3928,10 +3969,14 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) {
const groupName = normalizeGroupName(meta?.groupName || '') const groupName = normalizeGroupName(meta?.groupName || '')
const hammaddeAdi = String(meta?.hammaddeAdi || '').trim() const hammaddeAdi = String(meta?.hammaddeAdi || '').trim()
const effectiveGroupName = groupName || 'TANIMSIZ' const effectiveGroupName = groupName || 'TANIMSIZ'
const mtBolumID = (meta?.mtBolumID > 0 ? meta.mtBolumID : mappingMtBolumID) || 0 // The product-type mapping is authoritative for the required part. A raw
const metaParcaCandidate = normalizeGroupName((meta?.parcaAdi || '').trim()) // material type can be required for multiple parts, while its master
// Defensive: if backend/lookup accidentally returns group label (DT/TP/...) as part name, ignore it. // record only carries one default MT section. Using that default here
const desiredParcaAdi = normalizeGroupName((metaParcaCandidate && !isKnownGroupName(metaParcaCandidate)) ? metaParcaCandidate : mappingParcaAdi) // incorrectly made an existing recipe row satisfy every mapped part.
const mtBolumID = mappingMtBolumID > 0 ? mappingMtBolumID : (meta?.mtBolumID || 0)
const desiredParcaAdi = mappingMtBolumID > 0
? mappingParcaAdi
: normalizeGroupName(meta?.parcaAdi || mappingParcaAdi)
const anyMatch = flatDetailRows.value.find(r => { const anyMatch = flatDetailRows.value.find(r => {
if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false
@@ -5062,13 +5107,31 @@ async function saveChanges () {
// If we created a new OnML (no-cost), switch to has-cost detail mode. // If we created a new OnML (no-cost), switch to has-cost detail mode.
if (isNoCostDetail.value && newOnMLNo > 0) { if (isNoCostDetail.value && newOnMLNo > 0) {
router.replace({ await router.replace({
name: 'production-product-costing-has-cost-detail', name: 'production-product-costing-has-cost-detail',
query: { query: {
n_onml_no: String(newOnMLNo), n_onml_no: String(newOnMLNo),
urun_kodu: String(header?.UrunKodu || productCode.value || '').trim() urun_kodu: String(header?.UrunKodu || productCode.value || '').trim()
} }
}) })
$q.dialog({
title: 'Maliyet Kaydedildi',
message: 'Maliyeti olmayan urunler listesine donup kaldiginiz yerden devam etmek ister misiniz?',
ok: {
label: 'Listeye Don',
color: 'primary',
icon: 'arrow_back'
},
cancel: {
label: 'Bu Sayfada Devam Et',
color: 'grey-7',
flat: true
},
persistent: true
}).onOk(() => {
router.push({ name: 'production-product-costing-no-cost' })
})
return return
} }
@@ -5078,6 +5141,24 @@ async function saveChanges () {
window.setTimeout(() => { window.setTimeout(() => {
try { refreshLast10Warnings() } catch {} try { refreshLast10Warnings() } catch {}
}, 1200) }, 1200)
$q.dialog({
title: 'Maliyet Guncellendi',
message: 'Mevcut maliyeti olan urunler listesine donup kaldiginiz yerden devam etmek ister misiniz?',
ok: {
label: 'Listeye Don',
color: 'primary',
icon: 'arrow_back'
},
cancel: {
label: 'Bu Sayfada Devam Et',
color: 'grey-7',
flat: true
},
persistent: true
}).onOk(() => {
router.push({ name: 'production-product-costing-has-cost' })
})
} catch (e) { } catch (e) {
// Surface backend message (http.Error text) when available. // Surface backend message (http.Error text) when available.
const msg = String( const msg = String(
@@ -5717,12 +5798,69 @@ watch(
} }
.pcd-history-table :deep(.q-table thead th) { .pcd-history-table :deep(.q-table thead th) {
font-size: 12px; font-size: 9.5px;
line-height: 1.1;
white-space: normal;
padding: 3px 2px;
} }
.pcd-history-table :deep(.q-table tbody td) { .pcd-history-table :deep(.q-table tbody td) {
font-size: 12px; font-size: 9.5px;
vertical-align: top; vertical-align: top;
padding: 2px;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.pcd-history-table :deep(.q-table__middle) {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.pcd-history-table :deep(.q-table) {
table-layout: fixed;
width: 100%;
min-width: 0;
}
.pcd-history-table :deep(.q-table th),
.pcd-history-table :deep(.q-table td) {
min-width: 0 !important;
}
.pcd-history-table :deep(.q-table th:nth-child(1)), .pcd-history-table :deep(.q-table td:nth-child(1)) { width: 5% !important; }
.pcd-history-table :deep(.q-table th:nth-child(2)), .pcd-history-table :deep(.q-table td:nth-child(2)) { width: 6% !important; }
.pcd-history-table :deep(.q-table th:nth-child(3)), .pcd-history-table :deep(.q-table td:nth-child(3)) { width: 7% !important; }
.pcd-history-table :deep(.q-table th:nth-child(4)), .pcd-history-table :deep(.q-table td:nth-child(4)) { width: 6% !important; }
.pcd-history-table :deep(.q-table th:nth-child(5)), .pcd-history-table :deep(.q-table td:nth-child(5)) { width: 8% !important; }
.pcd-history-table :deep(.q-table th:nth-child(6)), .pcd-history-table :deep(.q-table td:nth-child(6)) { width: 7% !important; }
.pcd-history-table :deep(.q-table th:nth-child(7)), .pcd-history-table :deep(.q-table td:nth-child(7)) { width: 10% !important; }
.pcd-history-table :deep(.q-table th:nth-child(8)), .pcd-history-table :deep(.q-table td:nth-child(8)) { width: 5% !important; }
.pcd-history-table :deep(.q-table th:nth-child(9)), .pcd-history-table :deep(.q-table td:nth-child(9)) { width: 7% !important; }
.pcd-history-table :deep(.q-table th:nth-child(10)), .pcd-history-table :deep(.q-table td:nth-child(10)) { width: 5% !important; }
.pcd-history-table :deep(.q-table th:nth-child(11)), .pcd-history-table :deep(.q-table td:nth-child(11)) { width: 7% !important; }
.pcd-history-table :deep(.q-table th:nth-child(12)), .pcd-history-table :deep(.q-table td:nth-child(12)) { width: 5% !important; }
.pcd-history-table :deep(.q-table th:nth-child(13)), .pcd-history-table :deep(.q-table td:nth-child(13)) { width: 3% !important; }
.pcd-history-table :deep(.q-table th:nth-child(14)), .pcd-history-table :deep(.q-table td:nth-child(14)) { width: 5% !important; }
.pcd-history-table :deep(.q-table th:nth-child(15)), .pcd-history-table :deep(.q-table td:nth-child(15)) { width: 6% !important; }
.pcd-history-table :deep(.q-table th:nth-child(16)), .pcd-history-table :deep(.q-table td:nth-child(16)) { width: 4% !important; }
.pcd-history-table :deep(.q-table th:nth-child(17)), .pcd-history-table :deep(.q-table td:nth-child(17)) { width: 4% !important; }
.pcd-history-table :deep(.pcd-history-source-chip) {
min-width: 0;
max-width: 100%;
padding: 2px 4px;
font-size: 9px;
}
.pcd-history-table :deep(.q-btn) {
min-width: 0;
padding-left: 5px;
padding-right: 5px;
font-size: 9px;
} }
.pcd-history-table :deep(.pcd-history-row-purchase td) { .pcd-history-table :deep(.pcd-history-row-purchase td) {
+102 -4
View File
@@ -49,6 +49,14 @@
:loading="loading" :loading="loading"
@click="fetchRows" @click="fetchRows"
/> />
<q-btn
label="Excel'e Aktar"
icon="download"
color="primary"
outline
:disable="loading || rows.length === 0"
@click="exportVisibleRows"
/>
<div class="npc-missing-count text-caption text-grey-7"> <div class="npc-missing-count text-caption text-grey-7">
Maliyeti Girilmemis Satir Sayisi: <b>{{ missingCostRowCount }}</b> Maliyeti Girilmemis Satir Sayisi: <b>{{ missingCostRowCount }}</b>
</div> </div>
@@ -176,7 +184,7 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { usePermission } from 'src/composables/usePermission' import { usePermission } from 'src/composables/usePermission'
@@ -244,6 +252,9 @@ const columns = [
style: 'width:7%', style: 'width:7%',
headerStyle: 'width:7%' headerStyle: 'width:7%'
}, },
{ name: 'ceketDepoGiris', label: 'C-Ceket Depo Giris', field: 'ceketDepoGiris', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width:7%', headerStyle: 'width:7%' },
{ name: 'pantolonDepoGiris', label: 'P-Pantolon Depo Giris', field: 'pantolonDepoGiris', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width:7%', headerStyle: 'width:7%' },
{ name: 'yelekDepoGiris', label: 'Y-Yelek Depo Giris', field: 'yelekDepoGiris', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width:7%', headerStyle: 'width:7%' },
{ {
name: 'sMModelKodu', name: 'sMModelKodu',
label: 'Model Kodu', label: 'Model Kodu',
@@ -262,6 +273,34 @@ const columns = [
] ]
const columnFilters = reactive({}) const columnFilters = reactive({})
const LIST_STATE_STORAGE_KEY = 'bssapp:production-product-costing:no-cost-list-state:v1'
function persistListState () {
try {
sessionStorage.setItem(LIST_STATE_STORAGE_KEY, JSON.stringify({
filters: {
search: String(filters.search || ''),
fromDate: String(filters.fromDate || '')
},
columnFilters: JSON.parse(JSON.stringify(columnFilters)),
scrollY: Number(window.scrollY || 0)
}))
} catch {}
}
function restoreListState () {
try {
const raw = sessionStorage.getItem(LIST_STATE_STORAGE_KEY)
if (!raw) return 0
const saved = JSON.parse(raw)
filters.search = String(saved?.filters?.search || '')
filters.fromDate = String(saved?.filters?.fromDate || '2025-06-01')
Object.assign(columnFilters, saved?.columnFilters && typeof saved.columnFilters === 'object' ? saved.columnFilters : {})
return Number(saved?.scrollY || 0)
} catch {
return 0
}
}
function getColumnFilter (name) { function getColumnFilter (name) {
if (!columnFilters[name]) { if (!columnFilters[name]) {
@@ -276,7 +315,11 @@ function getColumnFilter (name) {
} }
function isNumericColumn (name) { function isNumericColumn (name) {
return name === 'lMMiktar_G' return ['lMMiktar_G', 'ceketDepoGiris', 'pantolonDepoGiris', 'yelekDepoGiris'].includes(name)
}
function formatQuantity (value) {
return Number(value || 0).toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
} }
function parseNumberFilter (value) { function parseNumberFilter (value) {
@@ -384,9 +427,11 @@ function clearAllColumnFilters () {
} }
let searchTimer = null let searchTimer = null
let filterWatchEnabled = false
watch( watch(
() => filters.search, () => filters.search,
() => { () => {
if (!filterWatchEnabled) return
clearTimeout(searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => {
fetchRows() fetchRows()
@@ -397,6 +442,7 @@ watch(
watch( watch(
() => filters.fromDate, () => filters.fromDate,
() => { () => {
if (!filterWatchEnabled) return
fetchRows() fetchRows()
} }
) )
@@ -430,6 +476,49 @@ function clearFilters () {
fetchRows() fetchRows()
} }
function escapeExcelCsvCell (value) {
let text = String(value ?? '')
// Prevent spreadsheet applications from evaluating data as a formula.
if (/^[=+\-@]/.test(text)) text = `'${text}`
return `"${text.replace(/"/g, '""')}"`
}
function formatExcelCsvCell (col, rawValue, displayValue) {
if (isNumericColumn(col?.name)) {
const numericValue = Number(rawValue)
if (Number.isFinite(numericValue)) return String(numericValue).replace('.', ',')
}
return escapeExcelCsvCell(displayValue)
}
function exportVisibleRows () {
const visibleRows = Array.isArray(rows.value) ? rows.value : []
if (visibleRows.length === 0) {
$q.notify({ type: 'warning', message: 'Excel aktarimi icin ekranda satir bulunamadi.', position: 'top-right' })
return
}
const exportColumns = columns.filter(col => col.name !== 'open')
const csvLines = [
exportColumns.map(col => escapeExcelCsvCell(col.label)).join(';'),
...visibleRows.map(row => exportColumns.map(col => {
const rawValue = typeof col.field === 'function' ? col.field(row) : row?.[col.field]
const displayValue = typeof col.format === 'function' ? col.format(rawValue, row) : rawValue
return formatExcelCsvCell(col, rawValue, displayValue)
}).join(';'))
]
const blob = new Blob([`\uFEFF${csvLines.join('\r\n')}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `maliyeti_olmayan_urunler_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
function openRow (row) { function openRow (row) {
const productCode = String(row?.sMModelKodu || '').trim() const productCode = String(row?.sMModelKodu || '').trim()
const recipeCode = String(row?.sKodu || '').trim() const recipeCode = String(row?.sKodu || '').trim()
@@ -449,6 +538,8 @@ function openRow (row) {
recipe_code: recipeCode recipe_code: recipeCode
}) })
persistListState()
router.push({ router.push({
name: 'production-product-costing-has-cost-detail', name: 'production-product-costing-has-cost-detail',
query: { query: {
@@ -460,9 +551,16 @@ function openRow (row) {
}) })
} }
onMounted(() => { onMounted(async () => {
if (!canReadOrder.value) return if (!canReadOrder.value) return
fetchRows() const savedScrollY = restoreListState()
await nextTick()
filterWatchEnabled = true
await fetchRows()
if (savedScrollY > 0) {
await nextTick()
window.scrollTo({ top: savedScrollY, behavior: 'auto' })
}
}) })
</script> </script>