diff --git a/svc/.env.local b/svc/.env.local index 8c10980..4cb1ba3 100644 --- a/svc/.env.local +++ b/svc/.env.local @@ -33,6 +33,23 @@ PDF_FONT_DIR=D:\\baggitekstilas\\software projects\\bssapp\\bssapp\\svc\\fonts API_HOST=0.0.0.0 API_PORT=8080 +# =============================== +# PRODUCT PERFORMANCE CACHE +# =============================== +# MSSQL yogun kullanildigi icin uygulama acilisinda cache isi calismaz. +# Ilk full refresh 19:00 sonrasi manuel calistirilmali. +PRODUCT_PERFORMANCE_ENABLED=true +PRODUCT_PERFORMANCE_RUN_ON_STARTUP=false +PRODUCT_PERFORMANCE_DELTA_DAYS=45 +PRODUCT_PERFORMANCE_DELTA_HHMM=02:00 +PRODUCT_PERFORMANCE_DELTA_TIMEOUT_HOURS=6 +PRODUCT_PERFORMANCE_FULL_ENABLED=true +PRODUCT_PERFORMANCE_FULL_WEEKDAY=saturday +PRODUCT_PERFORMANCE_FULL_HHMM=16:00 +PRODUCT_PERFORMANCE_FULL_START_DATE=2022-01-01 +PRODUCT_PERFORMANCE_FULL_TIMEOUT_HOURS=18 +PRODUCT_PERFORMANCE_RESUME_ON_STARTUP=true + AZURE_TRANSLATOR_KEY=d055c693-a84e-4594-8aef-a6c05c42623a AZURE_TRANSLATOR_ENDPOINT=https://api.cognitive.microsofttranslator.com diff --git a/svc/cmd/product_performance_refresh/main.go b/svc/cmd/product_performance_refresh/main.go new file mode 100644 index 0000000..6a3b658 --- /dev/null +++ b/svc/cmd/product_performance_refresh/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "bssapp-backend/db" + "bssapp-backend/queries" + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "runtime" + "strconv" + "time" + + "github.com/joho/godotenv" +) + +func main() { + mode := flag.String("mode", "full", "refresh mode: full or delta") + stage := flag.String("stage", "all", "refresh stage: all, sales, stock, price, or kpi") + resumeAfter := flag.Int("resume-after", 0, "skip this many source rows before inserting; implies skip-delete") + skipDelete := flag.Bool("skip-delete", false, "skip deleting cache rows for the selected stage") + start := flag.String("start", "2022-01-01", "start date in YYYY-MM-DD") + end := flag.String("end", "", "end date in YYYY-MM-DD; default today") + mssqlTimeoutSec := flag.Int("mssql-timeout-sec", 43200, "MSSQL connection/read timeout in seconds for long refresh jobs") + flag.Parse() + + _ = godotenv.Load(".env", "mail.env") + if runtime.GOOS == "windows" { + _ = godotenv.Overload(".env.local") + } + if *mssqlTimeoutSec > 0 { + timeout := strconv.Itoa(*mssqlTimeoutSec) + _ = os.Setenv("MSSQL_CONNECTION_TIMEOUT_SEC", timeout) + _ = os.Setenv("MSSQL_DIAL_TIMEOUT_SEC", timeout) + } + + if err := db.ConnectMSSQL(); err != nil { + log.Fatalf("mssql connect failed: %v", err) + } + defer db.MssqlDB.Close() + + pg, err := db.ConnectPostgres() + if err != nil { + log.Fatalf("postgres connect failed: %v", err) + } + defer pg.Close() + + startDate, err := time.Parse("2006-01-02", *start) + if err != nil { + log.Fatalf("invalid start date: %v", err) + } + var endDate time.Time + if *end == "" { + endDate = time.Now() + } else { + endDate, err = time.Parse("2006-01-02", *end) + if err != nil { + log.Fatalf("invalid end date: %v", err) + } + } + + refreshTimeout := time.Duration(*mssqlTimeoutSec) * time.Second + ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout) + defer cancel() + + result, err := queries.RefreshProductPerformance(ctx, pg, queries.ProductPerformanceRefreshRequest{ + Mode: *mode, + Stage: *stage, + ResumeAfter: *resumeAfter, + SkipDelete: *skipDelete, + StartDate: startDate, + EndDate: endDate, + }) + if err != nil { + log.Fatalf("refresh failed: %v", err) + } + out, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(out)) +} diff --git a/svc/local-api.err.log b/svc/local-api.err.log new file mode 100644 index 0000000..d8ec1ad --- /dev/null +++ b/svc/local-api.err.log @@ -0,0 +1 @@ +exit status 0xffffffff diff --git a/svc/local-api.out.log b/svc/local-api.out.log new file mode 100644 index 0000000..c72c3b8 --- /dev/null +++ b/svc/local-api.out.log @@ -0,0 +1,299 @@ +time=2026-06-29T15:41:11.450+03:00 level=INFO msg="backend start" app=bssapp-backend scope=main +time=2026-06-29T15:41:11.491+03:00 level=INFO msg="🔥🔥🔥 BSSAPP BACKEND STARTED — LOGIN ROUTE SHOULD EXIST 🔥🔥🔥" app=bssapp-backend +time=2026-06-29T15:41:11.523+03:00 level=INFO msg="🔐 JWT_SECRET yüklendi" app=bssapp-backend +MSSQL baglantisi basarili (connection timeout=120s, dial timeout=120s) +URETIM MSSQL baglantisi basarili (connection timeout=120s, dial timeout=120s) +time=2026-06-29T15:41:11.988+03:00 level=INFO msg="PostgreSQL baÄŸlantısı baÅŸarılı" app=bssapp-backend +time=2026-06-29T15:41:12.338+03:00 level=INFO msg="✅ Admin dept permissions seeded" app=bssapp-backend +time=2026-06-29T15:41:12.338+03:00 level=INFO msg="🟢 auditlog Init called, buffer: 1000" app=bssapp-backend +time=2026-06-29T15:41:12.338+03:00 level=INFO msg="🕵️ AuditLog sistemi başlatıldı (buffer=1000)" app=bssapp-backend +time=2026-06-29T15:41:12.338+03:00 level=INFO msg="🟢 auditlog worker STARTED" app=bssapp-backend +time=2026-06-29T15:41:12.447+03:00 level=INFO msg="[TranslationPerf] index_ready sql=\"CREATE EXTENSION IF NOT EXISTS pg_trgm\"" app=bssapp-backend +time=2026-06-29T15:41:12.553+03:00 level=INFO msg="[TranslationPerf] index_ready sql=\"CREATE INDEX IF NOT EXISTS idx_mk_translator_t_key_lang ON mk_translator (t_key, lang_code)\"" app=bssapp-backend +time=2026-06-29T15:41:12.662+03:00 level=INFO msg="[TranslationPerf] index_ready sql=\"CREATE INDEX IF NOT EXISTS idx_mk_translator_status_lang_updated ON mk_translator (status, lang_code...\"" app=bssapp-backend +time=2026-06-29T15:41:12.767+03:00 level=INFO msg="[TranslationPerf] index_ready sql=\"CREATE INDEX IF NOT EXISTS idx_mk_translator_manual_status ON mk_translator (is_manual, status)\"" app=bssapp-backend +time=2026-06-29T15:41:12.871+03:00 level=INFO msg="[TranslationPerf] index_ready sql=\"CREATE INDEX IF NOT EXISTS idx_mk_translator_source_type_expr ON mk_translator ((COALESCE(NULLIF(pro...\"" app=bssapp-backend +time=2026-06-29T15:41:12.973+03:00 level=INFO msg="[TranslationPerf] index_ready sql=\"CREATE INDEX IF NOT EXISTS idx_mk_translator_source_text_trgm ON mk_translator USING gin (source_tex...\"" app=bssapp-backend +time=2026-06-29T15:41:13.083+03:00 level=INFO msg="[TranslationPerf] index_ready sql=\"CREATE INDEX IF NOT EXISTS idx_mk_translator_translated_text_trgm ON mk_translator USING gin (transl...\"" app=bssapp-backend +time=2026-06-29T15:41:28.657+03:00 level=INFO msg="✉️ Graph Mailer hazır (App-only token) | from=baggiss@baggi.com.tr" app=bssapp-backend +time=2026-06-29T15:41:28.657+03:00 level=INFO msg="✉️ Graph Mailer hazır" app=bssapp-backend +📋 [DEBUG] İlk 10 kullanıcı: + - 1 : ctengiz + - 2 : ali.kale + - 5 : mehmet.keçeci + - 6 : mert.keçeci + - 7 : samet.keçeci + - 9 : orhan.caliskan + - 10 : nilgun.sara + - 14 : rustem.kurbanov + - 15 : caner.akyol + - 16 : kemal.matyakupov +time=2026-06-29T15:41:29.664+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/auth/login [auth:login]" app=bssapp-backend +time=2026-06-29T15:41:30.592+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/auth/refresh [auth:refresh]" app=bssapp-backend +time=2026-06-29T15:41:31.591+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/password/forgot [auth:update]" app=bssapp-backend +time=2026-06-29T15:41:32.507+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/password/reset/validate/{token} [auth:view]" app=bssapp-backend +time=2026-06-29T15:41:33.468+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/password/reset [auth:update]" app=bssapp-backend +time=2026-06-29T15:41:34.431+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/password/change [auth:update]" app=bssapp-backend +time=2026-06-29T15:41:35.355+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/activity-logs [system:read]" app=bssapp-backend +time=2026-06-29T15:41:36.316+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/test-mail [system:update]" app=bssapp-backend +time=2026-06-29T15:41:37.229+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/market-mail-mappings/lookups [system:update]" app=bssapp-backend +time=2026-06-29T15:41:38.104+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/market-mail-mappings [system:update]" app=bssapp-backend +time=2026-06-29T15:41:39.006+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/system/market-mail-mappings/{marketId} [system:update]" app=bssapp-backend +time=2026-06-29T15:41:39.955+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/costing-mail-mappings/lookups [system:update]" app=bssapp-backend +time=2026-06-29T15:41:40.895+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/costing-mail-mappings [system:update]" app=bssapp-backend +time=2026-06-29T15:41:41.853+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/system/costing-mail-mappings/{group} [system:update]" app=bssapp-backend +time=2026-06-29T15:41:42.755+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/pricing-mail-mappings/lookups [system:update]" app=bssapp-backend +time=2026-06-29T15:41:43.666+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/pricing-mail-mappings [system:update]" app=bssapp-backend +time=2026-06-29T15:41:44.571+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/system/pricing-mail-mappings/{group} [system:update]" app=bssapp-backend +time=2026-06-29T15:41:45.491+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/order-price-list-mail-mappings/lookups [system:update]" app=bssapp-backend +time=2026-06-29T15:41:46.403+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/order-price-list-mail-mappings [system:update]" app=bssapp-backend +time=2026-06-29T15:41:47.355+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/system/order-price-list-mail-mappings/{group} [system:update]" app=bssapp-backend +time=2026-06-29T15:41:48.332+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/order-price-list-user-price-groups/lookups [system:update]" app=bssapp-backend +time=2026-06-29T15:41:49.253+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/order-price-list-user-price-groups [system:update]" app=bssapp-backend +time=2026-06-29T15:41:50.165+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/system/order-price-list-user-price-groups/{id} [system:update]" app=bssapp-backend +time=2026-06-29T15:41:51.073+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/language/translations [language:update]" app=bssapp-backend +time=2026-06-29T15:41:51.973+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/language/translations/{id} [language:update]" app=bssapp-backend +time=2026-06-29T15:41:52.967+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/language/translations/upsert-missing [language:update]" app=bssapp-backend +time=2026-06-29T15:41:53.910+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/language/translations/sync-sources [language:update]" app=bssapp-backend +time=2026-06-29T15:41:54.837+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/language/translations/translate-selected [language:update]" app=bssapp-backend +time=2026-06-29T15:41:55.808+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/language/translations/bulk-approve [language:update]" app=bssapp-backend +time=2026-06-29T15:41:56.725+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/language/translations/bulk-update [language:update]" app=bssapp-backend +time=2026-06-29T15:41:57.669+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/roles/{id}/permissions [system:update]" app=bssapp-backend +time=2026-06-29T15:41:58.664+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/roles/{id}/permissions [system:update]" app=bssapp-backend +time=2026-06-29T15:41:59.554+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/users/{id}/permissions [system:update]" app=bssapp-backend +time=2026-06-29T15:42:00.500+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users/{id}/permissions [system:update]" app=bssapp-backend +time=2026-06-29T15:42:01.430+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/users/{id}/order-price-list-price-groups [user:update]" app=bssapp-backend +time=2026-06-29T15:42:02.375+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/users/order-price-list-price-groups/lookups [user:update]" app=bssapp-backend +time=2026-06-29T15:42:03.355+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/users/{id}/order-price-list-price-groups [user:update]" app=bssapp-backend +time=2026-06-29T15:42:04.295+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/permissions/routes [system:view]" app=bssapp-backend +time=2026-06-29T15:42:05.231+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/permissions/effective [system:view]" app=bssapp-backend +time=2026-06-29T15:42:06.173+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/permissions/matrix [system:view]" app=bssapp-backend +time=2026-06-29T15:42:07.109+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/role-dept-permissions/list [system:update]" app=bssapp-backend +time=2026-06-29T15:42:08.011+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/roles/{roleId}/departments/{deptCode}/permissions [system:update]" app=bssapp-backend +time=2026-06-29T15:42:08.934+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/roles/{roleId}/departments/{deptCode}/permissions [system:update]" app=bssapp-backend +time=2026-06-29T15:42:09.893+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/roles/{roleId}/departments/{deptCode}/members [system:update]" app=bssapp-backend +time=2026-06-29T15:42:10.775+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/roles/{roleId}/departments/{deptCode}/members [system:update]" app=bssapp-backend +time=2026-06-29T15:42:11.780+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/users/list [user:view]" app=bssapp-backend +time=2026-06-29T15:42:12.713+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users [user:insert]" app=bssapp-backend +time=2026-06-29T15:42:13.664+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/users/{id} [user:update]" app=bssapp-backend +time=2026-06-29T15:42:14.607+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/users/{id} [user:update]" app=bssapp-backend +time=2026-06-29T15:42:15.547+03:00 level=INFO msg="✅ Route+Perm registered → DELETE /api/users/{id} [user:delete]" app=bssapp-backend +time=2026-06-29T15:42:16.518+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users/{id}/admin-reset-password [user:update]" app=bssapp-backend +time=2026-06-29T15:42:17.416+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users/{id}/send-password-mail [user:update]" app=bssapp-backend +time=2026-06-29T15:42:18.324+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users/create [user:insert]" app=bssapp-backend +time=2026-06-29T15:42:19.277+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/departments [user:view]" app=bssapp-backend +time=2026-06-29T15:42:20.282+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/nebim-users [user:view]" app=bssapp-backend +time=2026-06-29T15:42:21.217+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/piyasalar [user:view]" app=bssapp-backend +time=2026-06-29T15:42:22.158+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/users-perm [user:view]" app=bssapp-backend +time=2026-06-29T15:42:23.136+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/roles-perm [user:view]" app=bssapp-backend +time=2026-06-29T15:42:24.117+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/departments-perm [user:view]" app=bssapp-backend +time=2026-06-29T15:42:25.118+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/modules [user:view]" app=bssapp-backend +time=2026-06-29T15:42:26.015+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/roles [user:view]" app=bssapp-backend +time=2026-06-29T15:42:26.927+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/accounts [customer:view]" app=bssapp-backend +time=2026-06-29T15:42:27.875+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/customer-list [customer:view]" app=bssapp-backend +time=2026-06-29T15:42:28.763+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/today-currency [finance:view]" app=bssapp-backend +time=2026-06-29T15:42:29.651+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/export-pdf [finance:export]" app=bssapp-backend +time=2026-06-29T15:42:30.569+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/export-pdf [finance:export]" app=bssapp-backend +time=2026-06-29T15:42:32.534+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/exportstamentheaderreport-pdf [finance:export]" app=bssapp-backend +time=2026-06-29T15:42:33.448+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/customer-balances [finance:view]" app=bssapp-backend +time=2026-06-29T15:42:34.348+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/customer-balances/export-pdf [finance:export]" app=bssapp-backend +time=2026-06-29T15:42:35.245+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/customer-balances/export-excel [finance:export]" app=bssapp-backend +time=2026-06-29T15:42:36.185+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/account-aging-statement [finance:view]" app=bssapp-backend +time=2026-06-29T15:42:37.086+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/account-aging-statement/export-pdf [finance:export]" app=bssapp-backend +time=2026-06-29T15:42:37.983+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/account-aging-statement/export-screen-pdf [finance:export]" app=bssapp-backend +time=2026-06-29T15:42:38.918+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/account-aging-statement/export-excel [finance:export]" app=bssapp-backend +time=2026-06-29T15:42:39.840+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/aged-customer-balance-list [finance:view]" app=bssapp-backend +time=2026-06-29T15:42:40.767+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/statements [finance:view]" app=bssapp-backend +time=2026-06-29T15:42:41.670+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/statements/{id}/details [finance:view]" app=bssapp-backend +time=2026-06-29T15:42:42.592+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/create [order:insert]" app=bssapp-backend +time=2026-06-29T15:42:43.557+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/update [order:update]" app=bssapp-backend +time=2026-06-29T15:42:44.465+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/{id}/bulk-due-date [order:update]" app=bssapp-backend +time=2026-06-29T15:42:45.376+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/get/{id} [order:view]" app=bssapp-backend +time=2026-06-29T15:42:46.342+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/list [order:view]" app=bssapp-backend +time=2026-06-29T15:42:47.266+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/production-list [order:update]" app=bssapp-backend +time=2026-06-29T15:42:48.177+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/production-items/cditem-lookups [order:view]" app=bssapp-backend +time=2026-06-29T15:42:49.084+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/production-items/{id} [order:view]" app=bssapp-backend +time=2026-06-29T15:42:50.038+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/orders/production-items/{id}/insert-missing [order:update]" app=bssapp-backend +time=2026-06-29T15:42:50.995+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/orders/production-items/{id}/validate [order:update]" app=bssapp-backend +time=2026-06-29T15:42:51.949+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/orders/production-items/{id}/apply [order:update]" app=bssapp-backend +time=2026-06-29T15:42:52.868+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/close-ready [order:update]" app=bssapp-backend +time=2026-06-29T15:42:53.773+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/orders/bulk-close [order:update]" app=bssapp-backend +time=2026-06-29T15:42:54.664+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/export [order:export]" app=bssapp-backend +time=2026-06-29T15:42:55.565+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/check/{id} [order:view]" app=bssapp-backend +time=2026-06-29T15:42:56.469+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/validate [order:insert]" app=bssapp-backend +time=2026-06-29T15:42:57.370+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/pdf/{id} [order:export]" app=bssapp-backend +time=2026-06-29T15:42:58.297+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/send-market-mail [order:read]" app=bssapp-backend +time=2026-06-29T15:42:59.232+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order-inventory [order:view]" app=bssapp-backend +time=2026-06-29T15:43:00.117+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orderpricelistb2b [order:view]" app=bssapp-backend +time=2026-06-29T15:43:01.035+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/min-price [order:view]" app=bssapp-backend +time=2026-06-29T15:43:01.945+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/products [order:view]" app=bssapp-backend +time=2026-06-29T15:43:02.855+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-detail [order:view]" app=bssapp-backend +time=2026-06-29T15:43:03.830+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-cditem [order:view]" app=bssapp-backend +time=2026-06-29T15:43:04.818+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-colors [order:view]" app=bssapp-backend +time=2026-06-29T15:43:05.732+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-newcolors [order:view]" app=bssapp-backend +time=2026-06-29T15:43:06.688+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-colorsize [order:view]" app=bssapp-backend +time=2026-06-29T15:43:07.614+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-secondcolor [order:view]" app=bssapp-backend +time=2026-06-29T15:43:08.550+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-newsecondcolor [order:view]" app=bssapp-backend +time=2026-06-29T15:43:09.459+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-attributes [order:view]" app=bssapp-backend +time=2026-06-29T15:43:10.377+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-item-attributes [order:view]" app=bssapp-backend +time=2026-06-29T15:43:11.293+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-stock-query [order:view]" app=bssapp-backend +time=2026-06-29T15:43:12.205+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-stock-attribute-options [order:view]" app=bssapp-backend +time=2026-06-29T15:43:13.156+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-stock-query-by-attributes [order:view]" app=bssapp-backend +time=2026-06-29T15:43:14.084+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-images [order:view]" app=bssapp-backend +time=2026-06-29T15:43:15.031+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-images/{id}/content [order:view]" app=bssapp-backend +time=2026-06-29T15:43:15.892+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/price-list/products [order:view]" app=bssapp-backend +time=2026-06-29T15:43:16.795+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/price-list/options [order:view]" app=bssapp-backend +time=2026-06-29T15:43:17.755+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/price-list/my-price-groups [order:view]" app=bssapp-backend +time=2026-06-29T15:43:18.648+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/price-list/campaigns [order:view]" app=bssapp-backend +time=2026-06-29T15:43:19.564+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/price-list/variant-rows [order:view]" app=bssapp-backend +time=2026-06-29T15:43:20.499+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/price-list/export-excel [order:view]" app=bssapp-backend +time=2026-06-29T15:43:21.398+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/price-list/export-pdf [order:view]" app=bssapp-backend +time=2026-06-29T15:43:22.375+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/price-list/export-notify [order:view]" app=bssapp-backend +time=2026-06-29T15:43:23.291+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-size-match/rules [order:view]" app=bssapp-backend +time=2026-06-29T15:43:24.221+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/products [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:25.123+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/products/options [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:26.103+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/products/export-all [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:27.014+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/products/price-list/export-excel [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:27.922+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/products/price-list/export-pdf [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:28.826+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/products/calculate-snapshots [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:29.795+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/products/{code}/price-history [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:30.748+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/products/{code}/price-history/delete-latest [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:31.725+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/products/{code}/price-history/delete-selected [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:32.615+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/products/save [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:33.535+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/product-performance [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:34.467+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/product-performance/summary [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:35.370+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/product-performance/markets [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:36.287+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/product-performance/countries [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:37.181+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/product-performance/customers [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:39.332+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/product-performance/refresh [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:40.243+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/product-series/definitions [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:41.188+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/product-series/definitions [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:42.100+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/pricing/product-series/definitions/{id} [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:43.038+03:00 level=INFO msg="✅ Route+Perm registered → DELETE /api/pricing/product-series/definitions/{id} [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:43.957+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/product-series/mappings [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:44.899+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/product-series/mappings/orphans [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:45.835+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/product-series/mappings/save [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:46.738+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/wholesale-campaigns [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:47.659+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/wholesale-campaigns/assignments [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:48.603+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/wholesale-campaigns/variants [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:49.594+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/wholesale-campaigns/variant-rows [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:50.549+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/wholesale-campaigns/save [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:51.451+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/wholesale-campaigns/{code}/campaign-history [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:52.372+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/wholesale-campaigns/{code}/campaign-history/delete-selected [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:53.287+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/brand-classification/lookups [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:54.164+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/brand-classification/brands [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:55.120+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/brand-classification/brands/sync [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:56.023+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/brand-classification/brand/{code}/group [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:56.949+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/brand-classification/brands/group-bulk [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:57.868+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/brand-group-currency [pricing:view]" app=bssapp-backend +time=2026-06-29T15:43:58.833+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/brand-group-currency/bulk-save [pricing:update]" app=bssapp-backend +time=2026-06-29T15:43:59.763+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/pricing-rules [pricing:view]" app=bssapp-backend +time=2026-06-29T15:44:00.680+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/pricing-rules/bulk-save [pricing:update]" app=bssapp-backend +time=2026-06-29T15:44:01.570+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/pricing-rules/import [pricing:update]" app=bssapp-backend +time=2026-06-29T15:44:02.472+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/pricing-rules/options [pricing:view]" app=bssapp-backend +time=2026-06-29T15:44:03.423+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/pricing-rules/parameters [pricing:view]" app=bssapp-backend +time=2026-06-29T15:44:04.293+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/pricing-rules/export-all [pricing:view]" app=bssapp-backend +time=2026-06-29T15:44:05.228+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/no-cost-products [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:06.168+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/has-cost-products [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:07.108+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/has-cost-history [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:08.025+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/has-cost-detail-groups [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:08.893+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/has-cost-detail-header [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:09.837+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/production-types [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:10.850+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/detail-editor-options [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:11.791+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/has-cost-detail-exchange-rates [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:12.719+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/has-cost-detail-line-history [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:13.671+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/has-cost-detail-similar-history [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:14.645+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/has-cost-detail-bulk-prices [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:15.528+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/has-cost-detail/last-detail [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:16.433+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/options/hammadde-by-nos [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:17.368+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/onml/save [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:18.358+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/onml/pdf [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:19.331+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/onml/delete [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:20.246+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/default-quantities [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:21.151+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/default-quantities/upsert [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:22.099+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/default-quantities/update-bulk [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:23.019+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/default-quantities/calc-avg [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:23.954+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/default-quantities/lookup [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:24.948+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/default-quantities/refresh [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:25.896+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/tbstok/exists-bulk [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:26.851+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/last10-warnings [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:27.756+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/options/urun-ana-grup [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:28.694+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/options/urun-alt-grup [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:29.598+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/options/urun-ana-alt-combos [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:30.600+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/options/mtbolum [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:31.588+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/maliyet-parca-eslestirme [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:32.613+03:00 level=INFO msg="✅ Route+Perm registered → DELETE /api/pricing/production-product-costing/maliyet-parca-eslestirme [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:33.550+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/maliyet-parca-eslestirme/upsert [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:34.514+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/maliyet-parca-eslestirme/set-active [costing:view]" app=bssapp-backend +time=2026-06-29T15:44:35.402+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/roles [user:view]" app=bssapp-backend +time=2026-06-29T15:44:36.301+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/departments [user:view]" app=bssapp-backend +time=2026-06-29T15:44:37.224+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/piyasalar [user:view]" app=bssapp-backend +time=2026-06-29T15:44:38.110+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/roles/{id}/departments [user:update]" app=bssapp-backend +time=2026-06-29T15:44:39.073+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/roles/{id}/piyasalar [user:update]" app=bssapp-backend +time=2026-06-29T15:44:40.025+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users/{id}/roles [user:update]" app=bssapp-backend +time=2026-06-29T15:44:41.004+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/admin/users/{id}/piyasa-sync [admin:user.update]" app=bssapp-backend +time=2026-06-29T15:44:41.005+03:00 level=INFO msg="🌍 CORS Allowed Origin: http://ss.baggi.com.tr/app" app=bssapp-backend +time=2026-06-29T15:44:41.006+03:00 level=INFO msg="🚀 Server running at: 0.0.0.0:8080" app=bssapp-backend +time=2026-06-29T15:44:41.006+03:00 level=INFO msg="🕓 Translation sync next run at 2026-06-30T04:00:00+03:00 (in 12h15m19s)" app=bssapp-backend +time=2026-06-29T15:44:43.006+03:00 level=INFO msg="[PricingFxFull] scheduled next_at=2026-07-06T06:00:00+03:00 in=158h15m17s" app=bssapp-backend +time=2026-06-29T15:44:43.673+03:00 level=INFO msg="[PricingFxDelta] ok (startup): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:44:46.006+03:00 level=INFO msg="[ProductSeriesFull] scheduled next_at=2026-06-30T05:00:00+03:00 in=13h15m14s" app=bssapp-backend +time=2026-06-29T15:45:16.284+03:00 level=INFO msg="✅ Brand sync ok (startup): total=197 upserted=197 deleted=0" app=bssapp-backend +time=2026-06-29T15:45:23.771+03:00 level=INFO msg="Pricing parameter sync source loaded: rows=1003 duration=40.7644195s" app=bssapp-backend +time=2026-06-29T15:45:24.388+03:00 level=INFO msg="Pricing parameter sync copy loaded: rows=1003 duration=41.3818724s" app=bssapp-backend +time=2026-06-29T15:45:24.796+03:00 level=INFO msg="Pricing parameter sync committed: rows=0 duration=41.7900213s" app=bssapp-backend +time=2026-06-29T15:45:24.797+03:00 level=INFO msg="Pricing parameter sync ok (startup): total=1003 upserted=0 deactivated=0" app=bssapp-backend +time=2026-06-29T15:45:43.997+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:46:43.997+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:47:44.011+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:48:44.034+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:49:44.010+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:50:44.005+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:51:44.007+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:52:43.996+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:53:44.001+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:54:44.001+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:55:43.999+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:56:44.002+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:57:44.001+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:58:44.090+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T15:59:43.999+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:00:44.002+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:01:43.999+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:02:44.005+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:03:44.000+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:04:43.999+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:05:44.055+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:06:43.998+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:07:44.001+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:08:44.009+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:09:44.000+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:10:43.997+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:10:44.115+03:00 level=INFO msg="[ProductPricingCalcJob] ok (startup): requested=15178 calculated=8893 skipped=6285 fx_date=2026-06-29 snapshot_publish=false products=0 sdprc_written=0 publish_skipped=0 interval_hours=24 duration=25m58s" app=bssapp-backend +time=2026-06-29T16:11:44.046+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:12:44.012+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:13:44.026+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:14:44.366+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:15:44.347+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:15:50.731+03:00 level=INFO msg="✅ Brand sync ok (scheduled): total=197 upserted=197 deleted=0" app=bssapp-backend +time=2026-06-29T16:16:02.711+03:00 level=INFO msg="Pricing parameter sync source loaded: rows=1003 duration=37.9122008s" app=bssapp-backend +time=2026-06-29T16:16:03.306+03:00 level=INFO msg="Pricing parameter sync copy loaded: rows=1003 duration=38.5077364s" app=bssapp-backend +time=2026-06-29T16:16:03.679+03:00 level=INFO msg="Pricing parameter sync committed: rows=0 duration=38.8807898s" app=bssapp-backend +time=2026-06-29T16:16:03.683+03:00 level=INFO msg="Pricing parameter sync ok (scheduled): total=1003 upserted=0 deactivated=0" app=bssapp-backend +time=2026-06-29T16:16:44.025+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:17:44.018+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:18:44.028+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:19:44.107+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:20:44.026+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:21:44.024+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:22:44.204+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:23:44.029+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:24:44.044+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:25:44.029+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:26:44.028+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:27:44.011+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:28:44.033+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:29:44.037+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:30:44.022+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:31:44.002+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend +time=2026-06-29T16:32:44.022+03:00 level=INFO msg="[PricingFxDelta] ok (scheduled): claimed=0 sdprc_written=0 interval_min=1 batch_size=200" app=bssapp-backend diff --git a/svc/main.go b/svc/main.go index 3b7f584..408c683 100644 --- a/svc/main.go +++ b/svc/main.go @@ -929,6 +929,36 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "pricing", "update", wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)), ) + bindV3(r, pgDB, + "/api/pricing/product-performance", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/product-performance/summary", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceSummaryHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/product-performance/markets", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceMarketsHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/product-performance/countries", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceCountriesHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/product-performance/customers", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceCustomersHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/product-performance/refresh", "POST", + "pricing", "update", + wrapV3(routes.PostProductPerformanceRefreshHandler(pgDB)), + ) bindV3(r, pgDB, "/api/pricing/product-series/definitions", "GET", "pricing", "view", @@ -1372,6 +1402,9 @@ func main() { if err := queries.EnsureProductSeriesAutoInfraTables(pgDB); err != nil { log.Println("product series auto infra bootstrap failed:", err) } + if err := queries.EnsureProductPerformanceTables(pgDB); err != nil { + log.Println("product performance infra bootstrap failed:", err) + } // ------------------------------------------------------- // ✉️ MAILER INIT @@ -1398,6 +1431,7 @@ func main() { startProductPricingFxDeltaScheduler(pgDB) startProductPricingFxFullScheduler(pgDB) startProductSeriesAutoSchedulers(pgDB) + startProductPerformanceScheduler(pgDB) handler := enableCORS( middlewares.GlobalAuthMiddleware( diff --git a/svc/models/product_performance.go b/svc/models/product_performance.go new file mode 100644 index 0000000..32bb33c --- /dev/null +++ b/svc/models/product_performance.go @@ -0,0 +1,113 @@ +package models + +type ProductPerformanceRow struct { + KpiDate string `json:"kpi_date"` + ProductCode string `json:"product_code"` + ColorCode string `json:"color_code"` + YakaKodu string `json:"yaka_kodu"` + ItemDescription string `json:"item_description"` + Kategori string `json:"kategori"` + Seri string `json:"seri"` + YasGrubu string `json:"yas_grubu"` + AskiliYan string `json:"askili_yan"` + UrunIlkGrubu string `json:"urun_ilk_grubu"` + UrunAnaGrubu string `json:"urun_ana_grubu"` + UrunAltGrubu string `json:"urun_alt_grubu"` + MarketKey string `json:"market_key"` + StockQty float64 `json:"stock_qty"` + SalesQty30 float64 `json:"sales_qty_30d"` + SalesQty90 float64 `json:"sales_qty_90d"` + SalesQty365 float64 `json:"sales_qty_365d"` + SalesQty730 float64 `json:"sales_qty_730d"` + SalesUSD30 float64 `json:"sales_usd_30d"` + SalesUSD90 float64 `json:"sales_usd_90d"` + SalesUSD365 float64 `json:"sales_usd_365d"` + AvgDailySales90 float64 `json:"avg_daily_sales_90d"` + StockDays90 float64 `json:"stock_days_90d"` + AvgPriceUSD90 float64 `json:"avg_price_usd_90d"` + CostPriceUSD float64 `json:"cost_price_usd"` + BasePriceUSD float64 `json:"base_price_usd"` + BasePriceTRY float64 `json:"base_price_try"` + GrossProfitUSD90 float64 `json:"gross_profit_usd_90d"` + GrossMargin90 float64 `json:"gross_margin_90d"` + CustomerCount90 int `json:"customer_count_90d"` + SalesIndex90 float64 `json:"sales_index_90d"` + PriceIndex90 float64 `json:"price_index_90d"` + MarginIndex90 float64 `json:"margin_index_90d"` + PerformanceScore float64 `json:"performance_score"` + PerformanceBucket string `json:"performance_bucket"` + Recommendation string `json:"recommendation"` + LastSaleDate string `json:"last_sale_date"` + LastRefNumber string `json:"last_ref_number"` + UpdatedAt string `json:"updated_at"` +} + +type ProductPerformanceSummary struct { + KpiDate string `json:"kpi_date"` + TotalRows int `json:"total_rows"` + StarCount int `json:"star_count"` + StockRisk int `json:"stock_risk"` + NoStockDemand int `json:"no_stock_demand"` + TotalStock float64 `json:"total_stock"` + StockCostUSD float64 `json:"stock_cost_usd"` + RiskCostUSD float64 `json:"risk_cost_usd"` + SalesQty90 float64 `json:"sales_qty_90d"` + SalesUSD90 float64 `json:"sales_usd_90d"` + GrossProfit90 float64 `json:"gross_profit_usd_90d"` + UpdatedAt string `json:"updated_at"` +} + +type ProductPerformanceMarketRow struct { + MarketKey string `json:"market_key"` + Kategori string `json:"kategori"` + Seri string `json:"seri"` + YasGrubu string `json:"yas_grubu"` + AskiliYan string `json:"askili_yan"` + UrunIlkGrubu string `json:"urun_ilk_grubu"` + UrunAnaGrubu string `json:"urun_ana_grubu"` + UrunAltGrubu string `json:"urun_alt_grubu"` + ProductCount int `json:"product_count"` + StarCount int `json:"star_count"` + StockRiskCount int `json:"stock_risk_count"` + StockQty float64 `json:"stock_qty"` + StockCostValueUSD float64 `json:"stock_cost_value_usd"` + RiskStockCostValueUSD float64 `json:"risk_stock_cost_value_usd"` + SalesQty90 float64 `json:"sales_qty_90d"` + SalesUSD90 float64 `json:"sales_usd_90d"` + GrossProfitUSD90 float64 `json:"gross_profit_usd_90d"` + AvgGrossMargin90 float64 `json:"avg_gross_margin_90d"` + AvgStockDays90 float64 `json:"avg_stock_days_90d"` +} + +type ProductPerformanceCountryRow struct { + Country string `json:"country"` + CustomerSegment string `json:"customer_segment"` + MarketKey string `json:"market_key"` + Kategori string `json:"kategori"` + Seri string `json:"seri"` + ProductCount int `json:"product_count"` + SalesQty90 float64 `json:"sales_qty_90d"` + SalesUSD90 float64 `json:"sales_usd_90d"` + AvgPriceUSD90 float64 `json:"avg_price_usd_90d"` + CustomerCount90 int `json:"customer_count_90d"` + InvoiceCount90 int `json:"invoice_count_90d"` + SalesQty365 float64 `json:"sales_qty_365d"` + SalesUSD365 float64 `json:"sales_usd_365d"` +} + +type ProductPerformanceCustomerRow struct { + Breakdown string `json:"breakdown"` + MarketKey string `json:"market_key"` + Country string `json:"country"` + CustomerSegment string `json:"customer_segment"` + CustomerCode string `json:"customer_code"` + CustomerName string `json:"customer_name"` + ProductCount int `json:"product_count"` + SalesQty90 float64 `json:"sales_qty_90d"` + SalesUSD90 float64 `json:"sales_usd_90d"` + AvgPriceUSD90 float64 `json:"avg_price_usd_90d"` + InvoiceCount90 int `json:"invoice_count_90d"` + SalesQty365 float64 `json:"sales_qty_365d"` + SalesUSD365 float64 `json:"sales_usd_365d"` + LastSaleDate string `json:"last_sale_date"` +} diff --git a/svc/product_performance_scheduler.go b/svc/product_performance_scheduler.go new file mode 100644 index 0000000..387b169 --- /dev/null +++ b/svc/product_performance_scheduler.go @@ -0,0 +1,251 @@ +package main + +import ( + "bssapp-backend/queries" + "context" + "database/sql" + "log" + "os" + "strconv" + "strings" + "sync/atomic" + "time" +) + +func startProductPerformanceScheduler(pgDB *sql.DB) { + enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_PERFORMANCE_ENABLED"))) + if enabled == "0" || enabled == "false" || enabled == "off" { + log.Println("Product performance scheduler disabled") + return + } + if pgDB == nil { + return + } + + deltaDays := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_DAYS", 45, 1, 365) + deltaHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_DELTA_HHMM", "02:00") + deltaTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_TIMEOUT_HOURS", 6, 1, 24) + + fullEnabled := productPerformanceEnvBool("PRODUCT_PERFORMANCE_FULL_ENABLED", true) + fullWeekday := productPerformanceEnvWeekday("PRODUCT_PERFORMANCE_FULL_WEEKDAY", time.Saturday) + fullHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_FULL_HHMM", "16:00") + fullTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_FULL_TIMEOUT_HOURS", 18, 1, 48) + fullStart := productPerformanceEnvDate("PRODUCT_PERFORMANCE_FULL_START_DATE", time.Date(2022, 1, 1, 0, 0, 0, 0, time.Local)) + + resumeOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_RESUME_ON_STARTUP", true) + runOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_RUN_ON_STARTUP", false) + + var running int32 + runRefresh := func(reason string, req queries.ProductPerformanceRefreshRequest, timeoutHours int) { + if !atomic.CompareAndSwapInt32(&running, 0, 1) { + log.Printf("[ProductPerformanceJob] skip (%s): already running", reason) + return + } + defer atomic.StoreInt32(&running, 0) + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutHours)*time.Hour) + defer cancel() + + result, err := queries.RefreshProductPerformance(ctx, pgDB, req) + if err != nil { + log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err) + return + } + log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d duration_ms=%d", + reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.DurationMS) + } + + runDelta := func(reason string) { + endDate := time.Now() + runRefresh(reason, queries.ProductPerformanceRefreshRequest{ + Mode: "delta", + Stage: "all", + StartDate: endDate.AddDate(0, 0, -deltaDays), + EndDate: endDate, + }, deltaTimeoutHours) + } + + runFull := func(reason string) { + runRefresh(reason, queries.ProductPerformanceRefreshRequest{ + Mode: "full", + Stage: "all", + StartDate: fullStart, + EndDate: time.Now(), + }, fullTimeoutHours) + } + + runFullResume := func(reason string) { + resumeAfter, ok := productPerformanceStockResumeAfter(pgDB, fullStart, time.Now()) + if !ok { + log.Printf("[ProductPerformanceJob] resume skip (%s): no committed full stock progress found", reason) + return + } + runRefresh(reason, queries.ProductPerformanceRefreshRequest{ + Mode: "full", + Stage: "stock", + ResumeAfter: resumeAfter, + SkipDelete: true, + StartDate: fullStart, + EndDate: time.Now(), + }, fullTimeoutHours) + } + + log.Printf("[ProductPerformanceJob] scheduled daily_delta=%s lookback_days=%d weekly_full=%t %s %s full_start=%s resume_on_startup=%t", + deltaHHMM, deltaDays, fullEnabled, fullWeekday.String(), fullHHMM, fullStart.Format("2006-01-02"), resumeOnStartup) + + go func() { + if resumeOnStartup { + time.Sleep(20 * time.Second) + runFullResume("startup-resume") + } else if runOnStartup { + time.Sleep(20 * time.Second) + runDelta("startup") + } + + for { + next := productPerformanceNextDaily(time.Now(), deltaHHMM) + log.Printf("[ProductPerformanceJob] daily delta next_at=%s in=%s", next.Format(time.RFC3339), time.Until(next).Round(time.Second)) + time.Sleep(time.Until(next)) + runDelta("daily-02") + } + }() + + if fullEnabled { + go func() { + for { + next := productPerformanceNextWeekly(time.Now(), fullWeekday, fullHHMM) + log.Printf("[ProductPerformanceJob] weekly full next_at=%s in=%s", next.Format(time.RFC3339), time.Until(next).Round(time.Second)) + time.Sleep(time.Until(next)) + runFull("weekly-full") + } + }() + } +} + +func productPerformanceStockResumeAfter(pgDB *sql.DB, startDate, endDate time.Time) (int, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := queries.EnsureProductPerformanceTables(pgDB); err != nil { + log.Printf("[ProductPerformanceJob] resume inspect failed: %v", err) + return 0, false + } + var stockRows int + var maxStockDate sql.NullTime + var maxKPIDate sql.NullTime + if err := pgDB.QueryRowContext(ctx, ` +SELECT COUNT(*), MAX(stock_date) +FROM mk_product_performance_stock_daily +WHERE stock_date BETWEEN $1 AND $2 +`, startDate, endDate).Scan(&stockRows, &maxStockDate); err != nil { + log.Printf("[ProductPerformanceJob] resume inspect stock failed: %v", err) + return 0, false + } + if stockRows <= 0 { + return 0, false + } + if err := pgDB.QueryRowContext(ctx, ` +SELECT MAX(kpi_date) +FROM mk_product_performance_kpi_daily +`).Scan(&maxKPIDate); err != nil { + log.Printf("[ProductPerformanceJob] resume inspect kpi failed: %v", err) + return 0, false + } + if maxStockDate.Valid && maxKPIDate.Valid && !maxStockDate.Time.After(maxKPIDate.Time) { + return 0, false + } + return stockRows, true +} + +func productPerformanceNextDaily(now time.Time, hhmm string) time.Time { + hour, minute := productPerformanceParseHHMM(hhmm, 2, 0) + next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) + if !next.After(now) { + next = next.AddDate(0, 0, 1) + } + return next +} + +func productPerformanceNextWeekly(now time.Time, weekday time.Weekday, hhmm string) time.Time { + hour, minute := productPerformanceParseHHMM(hhmm, 16, 0) + next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) + days := (int(weekday) - int(now.Weekday()) + 7) % 7 + next = next.AddDate(0, 0, days) + if !next.After(now) { + next = next.AddDate(0, 0, 7) + } + return next +} + +func productPerformanceParseHHMM(raw string, fallbackHour, fallbackMinute int) (int, int) { + parts := strings.Split(strings.TrimSpace(raw), ":") + if len(parts) != 2 { + return fallbackHour, fallbackMinute + } + h, herr := strconv.Atoi(parts[0]) + m, merr := strconv.Atoi(parts[1]) + if herr != nil || merr != nil || h < 0 || h > 23 || m < 0 || m > 59 { + return fallbackHour, fallbackMinute + } + return h, m +} + +func productPerformanceEnvString(name, fallback string) string { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback + } + return raw +} + +func productPerformanceEnvInt(name string, fallback, min, max int) int { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil || n < min || n > max { + return fallback + } + return n +} + +func productPerformanceEnvBool(name string, fallback bool) bool { + raw := strings.TrimSpace(strings.ToLower(os.Getenv(name))) + if raw == "" { + return fallback + } + return raw == "1" || raw == "true" || raw == "on" || raw == "yes" +} + +func productPerformanceEnvDate(name string, fallback time.Time) time.Time { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback + } + t, err := time.ParseInLocation("2006-01-02", raw, time.Local) + if err != nil { + return fallback + } + return t +} + +func productPerformanceEnvWeekday(name string, fallback time.Weekday) time.Weekday { + switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) { + case "0", "sunday", "pazar": + return time.Sunday + case "1", "monday", "pazartesi": + return time.Monday + case "2", "tuesday", "sali", "salı": + return time.Tuesday + case "3", "wednesday", "carsamba", "çarşamba": + return time.Wednesday + case "4", "thursday", "persembe", "perşembe": + return time.Thursday + case "5", "friday", "cuma": + return time.Friday + case "6", "saturday", "cumartesi": + return time.Saturday + default: + return fallback + } +} diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go new file mode 100644 index 0000000..173d4fa --- /dev/null +++ b/svc/queries/product_performance.go @@ -0,0 +1,963 @@ +package queries + +import ( + "bssapp-backend/db" + "bssapp-backend/models" + "context" + "database/sql" + "fmt" + "log" + "strings" + "time" +) + +type ProductPerformanceFilters struct { + Search string + ProductCode string + MarketKey string + Kategori string + Seri string + Bucket string + Limit int + Page int +} + +type ProductPerformanceRefreshRequest struct { + Mode string + Stage string + ResumeAfter int + SkipDelete bool + StartDate time.Time + EndDate time.Time +} + +type ProductPerformanceRefreshResult struct { + Mode string `json:"mode"` + Stage string `json:"stage"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + SalesRows int `json:"sales_rows"` + StockRows int `json:"stock_rows"` + KpiRows int `json:"kpi_rows"` + DurationMS int64 `json:"duration_ms"` +} + +func EnsureProductPerformanceTables(pg *sql.DB) error { + stmts := []string{ + ` +CREATE TABLE IF NOT EXISTS mk_product_performance_sales_daily ( + sales_date DATE NOT NULL, + product_code TEXT NOT NULL, + color_code TEXT NOT NULL DEFAULT '', + yaka_kodu TEXT NOT NULL DEFAULT '', + item_description TEXT NOT NULL DEFAULT '', + kategori TEXT NOT NULL DEFAULT '', + seri TEXT NOT NULL DEFAULT '', + yas_grubu TEXT NOT NULL DEFAULT '', + askili_yan TEXT NOT NULL DEFAULT '', + urun_ilk_grubu TEXT NOT NULL DEFAULT '', + urun_ana_grubu TEXT NOT NULL DEFAULT '', + urun_alt_grubu TEXT NOT NULL DEFAULT '', + market_key TEXT NOT NULL DEFAULT '', + channel_code TEXT NOT NULL DEFAULT '', + customer_country TEXT NOT NULL DEFAULT '', + customer_segment TEXT NOT NULL DEFAULT '', + customer_code TEXT NOT NULL DEFAULT '', + customer_name TEXT NOT NULL DEFAULT '', + sales_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_tl NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_usd NUMERIC(18,4) NOT NULL DEFAULT 0, + avg_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, + invoice_line_count INTEGER NOT NULL DEFAULT 0, + invoice_count INTEGER NOT NULL DEFAULT 0, + customer_count INTEGER NOT NULL DEFAULT 0, + last_ref_number TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pk_mk_product_performance_sales_daily PRIMARY KEY + (sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code) +)`, + `ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS customer_code TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS customer_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`, + ` +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'pk_mk_product_performance_sales_daily' + AND conrelid = 'mk_product_performance_sales_daily'::regclass + ) THEN + ALTER TABLE mk_product_performance_sales_daily DROP CONSTRAINT pk_mk_product_performance_sales_daily; + END IF; + ALTER TABLE mk_product_performance_sales_daily + ADD CONSTRAINT pk_mk_product_performance_sales_daily PRIMARY KEY + (sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code); +END $$`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_product_date ON mk_product_performance_sales_daily (product_code, sales_date DESC)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_market ON mk_product_performance_sales_daily (market_key, sales_date DESC)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_customer ON mk_product_performance_sales_daily (customer_code, sales_date DESC)`, + ` +CREATE TABLE IF NOT EXISTS mk_product_performance_stock_daily ( + stock_date DATE NOT NULL, + product_code TEXT NOT NULL, + color_code TEXT NOT NULL DEFAULT '', + yaka_kodu TEXT NOT NULL DEFAULT '', + stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + in_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + out_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + kpi_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + kpi_out_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_movement_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + production_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + purchase_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + consumption_out_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + count_diff_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pk_mk_product_performance_stock_daily PRIMARY KEY + (stock_date, product_code, color_code, yaka_kodu) +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_stock_product_date ON mk_product_performance_stock_daily (product_code, stock_date DESC)`, + ` +CREATE TABLE IF NOT EXISTS mk_product_performance_price_dim ( + product_code TEXT PRIMARY KEY, + cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, + base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, + base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0, + last_pricing_date DATE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +)`, + ` +CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily ( + kpi_date DATE NOT NULL, + product_code TEXT NOT NULL, + color_code TEXT NOT NULL DEFAULT '', + yaka_kodu TEXT NOT NULL DEFAULT '', + item_description TEXT NOT NULL DEFAULT '', + kategori TEXT NOT NULL DEFAULT '', + seri TEXT NOT NULL DEFAULT '', + yas_grubu TEXT NOT NULL DEFAULT '', + askili_yan TEXT NOT NULL DEFAULT '', + urun_ilk_grubu TEXT NOT NULL DEFAULT '', + urun_ana_grubu TEXT NOT NULL DEFAULT '', + urun_alt_grubu TEXT NOT NULL DEFAULT '', + market_key TEXT NOT NULL DEFAULT '', + stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_qty_30d NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_qty_90d NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_qty_365d NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_qty_730d NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_usd_30d NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0, + sales_usd_365d NUMERIC(18,4) NOT NULL DEFAULT 0, + avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + stock_days_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + avg_price_usd_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, + base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, + base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0, + gross_profit_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0, + gross_margin_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + customer_count_90d INTEGER NOT NULL DEFAULT 0, + sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + price_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + margin_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, + performance_score NUMERIC(18,6) NOT NULL DEFAULT 0, + performance_bucket TEXT NOT NULL DEFAULT '', + recommendation TEXT NOT NULL DEFAULT '', + last_sale_date DATE, + last_ref_number TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pk_mk_product_performance_kpi_daily PRIMARY KEY + (kpi_date, product_code, color_code, yaka_kodu, market_key) +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_kpi_bucket ON mk_product_performance_kpi_daily (kpi_date DESC, performance_bucket)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_kpi_market ON mk_product_performance_kpi_daily (kpi_date DESC, market_key, sales_index_90d DESC)`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`, + } + for _, stmt := range stmts { + if _, err := pg.Exec(stmt); err != nil { + return err + } + } + return nil +} + +func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) { + started := time.Now() + stage := productPerformanceRefreshStage(req.Stage) + log.Printf("[ProductPerformanceRefresh] start mode=%s stage=%s start=%s end=%s", req.Mode, stage, req.StartDate.Format("2006-01-02"), req.EndDate.Format("2006-01-02")) + if pg == nil { + return ProductPerformanceRefreshResult{}, fmt.Errorf("postgres db nil") + } + if db.MssqlDB == nil { + return ProductPerformanceRefreshResult{}, fmt.Errorf("mssql db nil") + } + log.Printf("[ProductPerformanceRefresh] ensure tables start") + if err := EnsureProductPerformanceTables(pg); err != nil { + return ProductPerformanceRefreshResult{}, err + } + log.Printf("[ProductPerformanceRefresh] ensure tables done") + mode := strings.ToLower(strings.TrimSpace(req.Mode)) + if mode == "" { + mode = "delta" + } + if req.EndDate.IsZero() { + req.EndDate = time.Now() + } + if req.StartDate.IsZero() { + if mode == "full" { + req.StartDate = time.Date(2022, 1, 1, 0, 0, 0, 0, req.EndDate.Location()) + } else { + req.StartDate = req.EndDate.AddDate(0, 0, -45) + } + } + req.StartDate = dateOnly(req.StartDate) + req.EndDate = dateOnly(req.EndDate) + shouldRun := func(name string) bool { + if stage == "all" { + return true + } + order := map[string]int{ + "sales": 1, + "stock": 2, + "price": 3, + "kpi": 4, + } + return order[name] >= order[stage] + } + + runStage := func(stage string, fn func(*sql.Tx) error) error { + log.Printf("[ProductPerformanceRefresh] %s tx begin", stage) + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err := fn(tx); err != nil { + return err + } + log.Printf("[ProductPerformanceRefresh] %s tx commit start", stage) + if err := tx.Commit(); err != nil { + return err + } + log.Printf("[ProductPerformanceRefresh] %s tx commit done elapsed=%s", stage, time.Since(started).Round(time.Second)) + return nil + } + + var salesRows int + if shouldRun("sales") { + if err := runStage("sales", func(tx *sql.Tx) error { + log.Printf("[ProductPerformanceRefresh] delete sales cache start") + if _, err := tx.ExecContext(ctx, `DELETE FROM mk_product_performance_sales_daily WHERE sales_date BETWEEN $1 AND $2`, req.StartDate, req.EndDate); err != nil { + return err + } + log.Printf("[ProductPerformanceRefresh] delete sales cache done") + log.Printf("[ProductPerformanceRefresh] sales refresh start") + rows, err := refreshProductPerformanceSales(ctx, tx, req.StartDate, req.EndDate) + if err != nil { + return err + } + salesRows = rows + log.Printf("[ProductPerformanceRefresh] sales refresh done rows=%d elapsed=%s", salesRows, time.Since(started).Round(time.Second)) + return nil + }); err != nil { + return ProductPerformanceRefreshResult{}, err + } + } else { + log.Printf("[ProductPerformanceRefresh] sales skipped stage=%s", stage) + } + + var stockRows int + if shouldRun("stock") { + rows, err := refreshProductPerformanceStockChunked(ctx, pg, req.StartDate, req.EndDate, started, req.SkipDelete || req.ResumeAfter > 0, req.ResumeAfter) + if err != nil { + return ProductPerformanceRefreshResult{}, err + } + stockRows = rows + } else { + log.Printf("[ProductPerformanceRefresh] stock skipped stage=%s", stage) + } + + if shouldRun("price") { + if err := runStage("price", func(tx *sql.Tx) error { + log.Printf("[ProductPerformanceRefresh] price refresh start") + if err := refreshProductPerformancePrices(ctx, tx); err != nil { + return err + } + log.Printf("[ProductPerformanceRefresh] price refresh done elapsed=%s", time.Since(started).Round(time.Second)) + return nil + }); err != nil { + return ProductPerformanceRefreshResult{}, err + } + } else { + log.Printf("[ProductPerformanceRefresh] price skipped stage=%s", stage) + } + + var kpiRows int + if shouldRun("kpi") { + if err := runStage("kpi", func(tx *sql.Tx) error { + log.Printf("[ProductPerformanceRefresh] kpi rebuild start") + rows, err := RebuildProductPerformanceKPI(ctx, tx, req.EndDate) + if err != nil { + return err + } + kpiRows = rows + log.Printf("[ProductPerformanceRefresh] kpi rebuild done rows=%d elapsed=%s", kpiRows, time.Since(started).Round(time.Second)) + return nil + }); err != nil { + return ProductPerformanceRefreshResult{}, err + } + } else { + log.Printf("[ProductPerformanceRefresh] kpi skipped stage=%s", stage) + } + + log.Printf("[ProductPerformanceRefresh] refresh done total_elapsed=%s", time.Since(started).Round(time.Second)) + return ProductPerformanceRefreshResult{ + Mode: mode, + Stage: stage, + StartDate: req.StartDate.Format("2006-01-02"), + EndDate: req.EndDate.Format("2006-01-02"), + SalesRows: salesRows, + StockRows: stockRows, + KpiRows: kpiRows, + DurationMS: time.Since(started).Milliseconds(), + }, nil +} + +func productPerformanceRefreshStage(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "all", "full": + return "all" + case "sales", "stock", "price", "kpi": + return strings.ToLower(strings.TrimSpace(raw)) + default: + return "all" + } +} + +func refreshProductPerformanceSales(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time) (int, error) { + log.Printf("[ProductPerformanceRefresh] sales mssql query start start=%s end=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceSalesSQL(), startDate, endDate) + if err != nil { + return 0, err + } + defer rows.Close() + log.Printf("[ProductPerformanceRefresh] sales mssql query returned, postgres insert start") + count := 0 + for rows.Next() { + var r productPerformanceSalesDaily + if err := rows.Scan( + &r.SalesDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, + &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.ChannelCode, + &r.CustomerCountry, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &r.SalesQty, &r.SalesTL, &r.SalesUSD, + &r.AvgPriceUSD, &r.InvoiceLineCount, &r.InvoiceCount, &r.CustomerCount, &r.LastRefNumber, + ); err != nil { + return count, err + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_product_performance_sales_daily ( + sales_date, product_code, color_code, yaka_kodu, item_description, + kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, channel_code, + customer_country, customer_segment, customer_code, customer_name, sales_qty, sales_tl, sales_usd, + avg_price_usd, invoice_line_count, invoice_count, customer_count, last_ref_number, updated_at +) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,now() +) +ON CONFLICT (sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code) +DO UPDATE SET + item_description=EXCLUDED.item_description, + kategori=EXCLUDED.kategori, + seri=EXCLUDED.seri, + yas_grubu=EXCLUDED.yas_grubu, + askili_yan=EXCLUDED.askili_yan, + urun_ilk_grubu=EXCLUDED.urun_ilk_grubu, + urun_ana_grubu=EXCLUDED.urun_ana_grubu, + urun_alt_grubu=EXCLUDED.urun_alt_grubu, + channel_code=EXCLUDED.channel_code, + customer_name=EXCLUDED.customer_name, + sales_qty=EXCLUDED.sales_qty, + sales_tl=EXCLUDED.sales_tl, + sales_usd=EXCLUDED.sales_usd, + avg_price_usd=EXCLUDED.avg_price_usd, + invoice_line_count=EXCLUDED.invoice_line_count, + invoice_count=EXCLUDED.invoice_count, + customer_count=EXCLUDED.customer_count, + last_ref_number=EXCLUDED.last_ref_number, + updated_at=now() +`, r.SalesDate, r.ProductCode, r.ColorCode, r.YakaKodu, r.ItemDescription, + r.Kategori, r.Seri, r.YasGrubu, r.AskiliYan, r.UrunIlkGrubu, r.UrunAnaGrubu, r.UrunAltGrubu, r.MarketKey, r.ChannelCode, + r.CustomerCountry, r.CustomerSegment, r.CustomerCode, r.CustomerName, r.SalesQty, r.SalesTL, r.SalesUSD, + r.AvgPriceUSD, r.InvoiceLineCount, r.InvoiceCount, r.CustomerCount, r.LastRefNumber); err != nil { + return count, err + } + count++ + if count%5000 == 0 { + log.Printf("[ProductPerformanceRefresh] sales inserted rows=%d", count) + } + } + return count, rows.Err() +} + +func refreshProductPerformanceStock(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time) (int, error) { + log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockSQL(), startDate, endDate) + if err != nil { + return 0, err + } + defer rows.Close() + log.Printf("[ProductPerformanceRefresh] stock mssql query returned, postgres insert start") + count := 0 + for rows.Next() { + var r productPerformanceStockDaily + if err := rows.Scan( + &r.StockDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.StockQty, + &r.InQty, &r.OutQty, &r.KpiInQty, &r.KpiOutQty, &r.SalesMovementQty, + &r.ProductionInQty, &r.PurchaseInQty, &r.ConsumptionOutQty, &r.CountDiffQty, + ); err != nil { + return count, err + } + if err := insertProductPerformanceStock(ctx, tx, r); err != nil { + return count, err + } + count++ + if count%5000 == 0 { + log.Printf("[ProductPerformanceRefresh] stock inserted rows=%d", count) + } + } + return count, rows.Err() +} + +func refreshProductPerformanceStockChunked(ctx context.Context, pg *sql.DB, startDate, endDate, started time.Time, skipDelete bool, resumeAfter int) (int, error) { + if skipDelete { + log.Printf("[ProductPerformanceRefresh] stock delete skipped resume_after=%d", resumeAfter) + } else { + log.Printf("[ProductPerformanceRefresh] stock delete tx begin") + deleteTx, err := pg.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer deleteTx.Rollback() + log.Printf("[ProductPerformanceRefresh] delete stock cache start") + if _, err := deleteTx.ExecContext(ctx, `DELETE FROM mk_product_performance_stock_daily WHERE stock_date BETWEEN $1 AND $2`, startDate, endDate); err != nil { + return 0, err + } + log.Printf("[ProductPerformanceRefresh] delete stock cache done") + log.Printf("[ProductPerformanceRefresh] stock delete tx commit start") + if err := deleteTx.Commit(); err != nil { + return 0, err + } + log.Printf("[ProductPerformanceRefresh] stock delete tx commit done elapsed=%s", time.Since(started).Round(time.Second)) + } + + log.Printf("[ProductPerformanceRefresh] stock refresh start") + log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockSQL(), startDate, endDate) + if err != nil { + return 0, err + } + defer rows.Close() + log.Printf("[ProductPerformanceRefresh] stock mssql query returned, postgres chunk insert start") + + const chunkSize = 5000 + count := 0 + seen := 0 + chunkRows := 0 + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + for rows.Next() { + var r productPerformanceStockDaily + if err := rows.Scan( + &r.StockDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.StockQty, + &r.InQty, &r.OutQty, &r.KpiInQty, &r.KpiOutQty, &r.SalesMovementQty, + &r.ProductionInQty, &r.PurchaseInQty, &r.ConsumptionOutQty, &r.CountDiffQty, + ); err != nil { + return count, err + } + seen++ + if resumeAfter > 0 && seen <= resumeAfter { + if seen%50000 == 0 || seen == resumeAfter { + log.Printf("[ProductPerformanceRefresh] stock resume skipped rows=%d", seen) + } + continue + } + if err := insertProductPerformanceStock(ctx, tx, r); err != nil { + return count, err + } + count++ + chunkRows++ + if chunkRows >= chunkSize { + log.Printf("[ProductPerformanceRefresh] stock chunk commit start inserted_rows=%d scanned_rows=%d", count, seen) + if err := tx.Commit(); err != nil { + return count, err + } + log.Printf("[ProductPerformanceRefresh] stock chunk commit done inserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second)) + tx, err = pg.BeginTx(ctx, nil) + if err != nil { + return count, err + } + chunkRows = 0 + } + } + if err := rows.Err(); err != nil { + return count, err + } + if chunkRows > 0 { + log.Printf("[ProductPerformanceRefresh] stock final chunk commit start inserted_rows=%d scanned_rows=%d", count, seen) + if err := tx.Commit(); err != nil { + return count, err + } + log.Printf("[ProductPerformanceRefresh] stock final chunk commit done inserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second)) + } else { + _ = tx.Rollback() + } + log.Printf("[ProductPerformanceRefresh] stock refresh done inserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second)) + return count, nil +} + +type productPerformanceStockExec interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +func insertProductPerformanceStock(ctx context.Context, exec productPerformanceStockExec, r productPerformanceStockDaily) error { + _, err := exec.ExecContext(ctx, ` +INSERT INTO mk_product_performance_stock_daily ( + stock_date, product_code, color_code, yaka_kodu, stock_qty, in_qty, out_qty, + kpi_in_qty, kpi_out_qty, sales_movement_qty, production_in_qty, purchase_in_qty, + consumption_out_qty, count_diff_qty, updated_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,now()) +ON CONFLICT (stock_date, product_code, color_code, yaka_kodu) +DO UPDATE SET + stock_qty=EXCLUDED.stock_qty, + in_qty=EXCLUDED.in_qty, + out_qty=EXCLUDED.out_qty, + kpi_in_qty=EXCLUDED.kpi_in_qty, + kpi_out_qty=EXCLUDED.kpi_out_qty, + sales_movement_qty=EXCLUDED.sales_movement_qty, + production_in_qty=EXCLUDED.production_in_qty, + purchase_in_qty=EXCLUDED.purchase_in_qty, + consumption_out_qty=EXCLUDED.consumption_out_qty, + count_diff_qty=EXCLUDED.count_diff_qty, + updated_at=now() +`, r.StockDate, r.ProductCode, r.ColorCode, r.YakaKodu, r.StockQty, + r.InQty, r.OutQty, r.KpiInQty, r.KpiOutQty, r.SalesMovementQty, + r.ProductionInQty, r.PurchaseInQty, r.ConsumptionOutQty, r.CountDiffQty) + return err +} + +func refreshProductPerformancePrices(ctx context.Context, tx *sql.Tx) error { + log.Printf("[ProductPerformanceRefresh] price mssql query start") + rows, err := db.MssqlDB.QueryContext(ctx, productPerformancePriceSQL()) + if err != nil { + return err + } + defer rows.Close() + log.Printf("[ProductPerformanceRefresh] price mssql query returned, postgres upsert start") + count := 0 + for rows.Next() { + var code string + var cost, usd, tryPrice float64 + var lastPricing sql.NullTime + if err := rows.Scan(&code, &cost, &usd, &tryPrice, &lastPricing); err != nil { + return err + } + var lp any + if lastPricing.Valid { + lp = lastPricing.Time + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_product_performance_price_dim (product_code, cost_price_usd, base_price_usd, base_price_try, last_pricing_date, updated_at) +VALUES ($1,$2,$3,$4,$5,now()) +ON CONFLICT (product_code) DO UPDATE SET + cost_price_usd=EXCLUDED.cost_price_usd, + base_price_usd=EXCLUDED.base_price_usd, + base_price_try=EXCLUDED.base_price_try, + last_pricing_date=EXCLUDED.last_pricing_date, + updated_at=now() +`, strings.TrimSpace(code), cost, usd, tryPrice, lp); err != nil { + return err + } + count++ + if count%5000 == 0 { + log.Printf("[ProductPerformanceRefresh] price upserted rows=%d", count) + } + } + log.Printf("[ProductPerformanceRefresh] price upsert done rows=%d", count) + return rows.Err() +} + +func RebuildProductPerformanceKPI(ctx context.Context, exec interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +}, kpiDate time.Time) (int, error) { + kpiDate = dateOnly(kpiDate) + if _, err := exec.ExecContext(ctx, `DELETE FROM mk_product_performance_kpi_daily WHERE kpi_date=$1`, kpiDate); err != nil { + return 0, err + } + res, err := exec.ExecContext(ctx, productPerformanceKPISQL(), kpiDate) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return int(n), nil +} + +func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanceFilters) ([]models.ProductPerformanceRow, int, error) { + if err := EnsureProductPerformanceTables(pg); err != nil { + return nil, 0, err + } + limit := f.Limit + if limit <= 0 || limit > 500 { + limit = 100 + } + page := f.Page + if page <= 0 { + page = 1 + } + where, args := productPerformanceWhere(f) + countQuery := `SELECT COUNT(*) FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + where + var total int + if err := pg.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + args = append(args, limit, (page-1)*limit) + rows, err := pg.QueryContext(ctx, ` +SELECT + to_char(kpi_date,'YYYY-MM-DD'), product_code, color_code, yaka_kodu, item_description, + kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, + sales_qty_30d, sales_qty_90d, sales_qty_365d, sales_qty_730d, + sales_usd_30d, sales_usd_90d, sales_usd_365d, avg_daily_sales_90d, + stock_days_90d, avg_price_usd_90d, cost_price_usd, base_price_usd, base_price_try, + gross_profit_usd_90d, gross_margin_90d, customer_count_90d, sales_index_90d, + price_index_90d, margin_index_90d, performance_score, performance_bucket, + recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number, + to_char(updated_at,'YYYY-MM-DD HH24:MI:SS') +FROM mk_product_performance_kpi_daily +WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)`+where+` +ORDER BY performance_score DESC, sales_qty_90d DESC, product_code +LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + out := make([]models.ProductPerformanceRow, 0, limit) + for rows.Next() { + var r models.ProductPerformanceRow + if err := rows.Scan( + &r.KpiDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, + &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.StockQty, + &r.SalesQty30, &r.SalesQty90, &r.SalesQty365, &r.SalesQty730, + &r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD365, &r.AvgDailySales90, + &r.StockDays90, &r.AvgPriceUSD90, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY, + &r.GrossProfitUSD90, &r.GrossMargin90, &r.CustomerCount90, &r.SalesIndex90, + &r.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket, + &r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt, + ); err != nil { + return nil, 0, err + } + out = append(out, r) + } + return out, total, rows.Err() +} + +func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) { + if err := EnsureProductPerformanceTables(pg); err != nil { + return models.ProductPerformanceSummary{}, err + } + var s models.ProductPerformanceSummary + err := pg.QueryRowContext(ctx, ` +SELECT + COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''), + COUNT(*), + COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN'), + COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI'), + COUNT(*) FILTER (WHERE performance_bucket='STOKSUZ_TALEP'), + COALESCE(SUM(stock_qty),0), + COALESCE(SUM(stock_qty * cost_price_usd),0), + COALESCE(SUM(CASE WHEN performance_bucket IN ('STOK_RISKI','TAKIP') AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0), + COALESCE(SUM(sales_qty_90d),0), + COALESCE(SUM(sales_usd_90d),0), + COALESCE(SUM(gross_profit_usd_90d),0), + COALESCE(to_char(MAX(updated_at),'YYYY-MM-DD HH24:MI:SS'),'') +FROM mk_product_performance_kpi_daily +WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) +`).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.UpdatedAt) + return s, err +} + +func ListProductPerformanceMarkets(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceMarketRow, error) { + if err := EnsureProductPerformanceTables(pg); err != nil { + return nil, err + } + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := pg.QueryContext(ctx, ` +SELECT + market_key, + MAX(kategori) AS kategori, + MAX(seri) AS seri, + MAX(yas_grubu) AS yas_grubu, + MAX(askili_yan) AS askili_yan, + MAX(urun_ilk_grubu) AS urun_ilk_grubu, + MAX(urun_ana_grubu) AS urun_ana_grubu, + MAX(urun_alt_grubu) AS urun_alt_grubu, + COUNT(DISTINCT product_code) AS product_count, + COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN') AS star_count, + COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI') AS stock_risk_count, + COALESCE(SUM(stock_qty),0) AS stock_qty, + COALESCE(SUM(stock_qty * cost_price_usd),0) AS stock_cost_value_usd, + COALESCE(SUM(CASE WHEN performance_bucket IN ('STOK_RISKI','TAKIP') AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0) AS risk_stock_cost_value_usd, + COALESCE(SUM(sales_qty_90d),0) AS sales_qty_90d, + COALESCE(SUM(sales_usd_90d),0) AS sales_usd_90d, + COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d, + COALESCE(AVG(NULLIF(gross_margin_90d,0)),0) AS avg_gross_margin_90d, + COALESCE(AVG(NULLIF(stock_days_90d,0)),0) AS avg_stock_days_90d +FROM mk_product_performance_kpi_daily +WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) +GROUP BY market_key +ORDER BY risk_stock_cost_value_usd DESC, stock_cost_value_usd DESC, sales_usd_90d DESC +LIMIT $1 +`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.ProductPerformanceMarketRow, 0, limit) + for rows.Next() { + var r models.ProductPerformanceMarketRow + if err := rows.Scan( + &r.MarketKey, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, + &r.ProductCount, &r.StarCount, &r.StockRiskCount, &r.StockQty, + &r.StockCostValueUSD, &r.RiskStockCostValueUSD, &r.SalesQty90, + &r.SalesUSD90, &r.GrossProfitUSD90, &r.AvgGrossMargin90, &r.AvgStockDays90, + ); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func ListProductPerformanceCountries(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceCountryRow, error) { + if err := EnsureProductPerformanceTables(pg); err != nil { + return nil, err + } + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := pg.QueryContext(ctx, ` +SELECT + customer_country, + customer_segment, + market_key, + MAX(kategori) AS kategori, + MAX(seri) AS seri, + COUNT(DISTINCT product_code) AS product_count, + COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d, + COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d, + CASE + WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) + END AS avg_price_usd_90d, + COALESCE(SUM(customer_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS customer_count_90d, + COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d, + COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_qty_365d, + COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d +FROM mk_product_performance_sales_daily +WHERE sales_date >= current_date - INTERVAL '364 days' +GROUP BY customer_country, customer_segment, market_key +ORDER BY sales_usd_90d DESC, sales_qty_90d DESC +LIMIT $1 +`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.ProductPerformanceCountryRow, 0, limit) + for rows.Next() { + var r models.ProductPerformanceCountryRow + if err := rows.Scan( + &r.Country, &r.CustomerSegment, &r.MarketKey, &r.Kategori, &r.Seri, + &r.ProductCount, &r.SalesQty90, &r.SalesUSD90, &r.AvgPriceUSD90, + &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty365, &r.SalesUSD365, + ); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func ListProductPerformanceCustomers(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceCustomerRow, error) { + if err := EnsureProductPerformanceTables(pg); err != nil { + return nil, err + } + if limit <= 0 || limit > 500 { + limit = 100 + } + mode := strings.ToLower(strings.TrimSpace(breakdown)) + if mode == "" { + mode = "market_customer" + } + selectMarket := "''" + selectCountry := "''" + selectSegment := "''" + groupCols := []string{"customer_code", "customer_name"} + switch mode { + case "country_customer": + selectCountry = "customer_country" + selectSegment = "customer_segment" + groupCols = append(groupCols, "customer_country", "customer_segment") + case "market_country_customer": + selectMarket = "market_key" + selectCountry = "customer_country" + selectSegment = "customer_segment" + groupCols = append(groupCols, "market_key", "customer_country", "customer_segment") + default: + mode = "market_customer" + selectMarket = "market_key" + groupCols = append(groupCols, "market_key") + } + + query := fmt.Sprintf(` +SELECT + $2::text AS breakdown, + %s AS market_key, + %s AS country, + %s AS customer_segment, + customer_code, + customer_name, + COUNT(DISTINCT product_code) AS product_count, + COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d, + COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d, + CASE + WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) + END AS avg_price_usd_90d, + COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d, + COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_qty_365d, + COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d, + COALESCE(to_char(MAX(sales_date),'YYYY-MM-DD'),'') AS last_sale_date +FROM mk_product_performance_sales_daily +WHERE sales_date >= current_date - INTERVAL '364 days' + AND COALESCE(customer_code,'') <> '' +GROUP BY %s +ORDER BY sales_usd_90d DESC, sales_qty_90d DESC +LIMIT $1 +`, selectMarket, selectCountry, selectSegment, strings.Join(groupCols, ", ")) + + rows, err := pg.QueryContext(ctx, query, limit, mode) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.ProductPerformanceCustomerRow, 0, limit) + for rows.Next() { + var r models.ProductPerformanceCustomerRow + if err := rows.Scan( + &r.Breakdown, &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, + &r.ProductCount, &r.SalesQty90, &r.SalesUSD90, &r.AvgPriceUSD90, &r.InvoiceCount90, + &r.SalesQty365, &r.SalesUSD365, &r.LastSaleDate, + ); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) { + parts := make([]string, 0, 6) + args := make([]any, 0, 6) + add := func(cond string, value any) { + args = append(args, value) + parts = append(parts, fmt.Sprintf(cond, len(args))) + } + if q := strings.TrimSpace(f.Search); q != "" { + args = append(args, q) + idx := len(args) + parts = append(parts, fmt.Sprintf(" AND (product_code ILIKE '%%' || $%d || '%%' OR item_description ILIKE '%%' || $%d || '%%')", idx, idx)) + } + if v := strings.TrimSpace(f.ProductCode); v != "" { + add(" AND product_code = $%d", v) + } + if v := strings.TrimSpace(f.MarketKey); v != "" { + add(" AND market_key = $%d", v) + } + if v := strings.TrimSpace(f.Kategori); v != "" { + add(" AND kategori = $%d", v) + } + if v := strings.TrimSpace(f.Seri); v != "" { + args = append(args, v) + idx := len(args) + parts = append(parts, fmt.Sprintf(" AND (seri = $%d OR urun_ana_grubu = $%d)", idx, idx)) + } + if v := strings.TrimSpace(f.Bucket); v != "" { + add(" AND performance_bucket = $%d", v) + } + return strings.Join(parts, ""), args +} + +func dateOnly(t time.Time) time.Time { + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) +} + +type productPerformanceSalesDaily struct { + SalesDate time.Time + ProductCode string + ColorCode string + YakaKodu string + ItemDescription string + Kategori string + Seri string + YasGrubu string + AskiliYan string + UrunIlkGrubu string + UrunAnaGrubu string + UrunAltGrubu string + MarketKey string + ChannelCode string + CustomerCountry string + CustomerSegment string + CustomerCode string + CustomerName string + SalesQty float64 + SalesTL float64 + SalesUSD float64 + AvgPriceUSD float64 + InvoiceLineCount int + InvoiceCount int + CustomerCount int + LastRefNumber string +} + +type productPerformanceStockDaily struct { + StockDate time.Time + ProductCode string + ColorCode string + YakaKodu string + StockQty float64 + InQty float64 + OutQty float64 + KpiInQty float64 + KpiOutQty float64 + SalesMovementQty float64 + ProductionInQty float64 + PurchaseInQty float64 + ConsumptionOutQty float64 + CountDiffQty float64 +} diff --git a/svc/queries/product_performance_sql.go b/svc/queries/product_performance_sql.go new file mode 100644 index 0000000..94374c9 --- /dev/null +++ b/svc/queries/product_performance_sql.go @@ -0,0 +1,562 @@ +package queries + +func productPerformanceSalesSQL() string { + return ` +;WITH SalesLines AS ( + SELECT + I.InvoiceLineID, + SalesDate = CAST(I.InvoiceDate AS date), + I.InvoiceHeaderID, + I.InvoiceNumber, + ProductCode = LTRIM(RTRIM(I.ItemCode)), + ColorCode = LTRIM(RTRIM(ISNULL(I.ColorCode, ''))), + YakaKodu = LTRIM(RTRIM(ISNULL(I.ItemDim2Code, ''))), + ItemDescription = dbo.HG_Temizlik(ISNULL(( + SELECT ItemDescription + FROM cdItemDesc WITH(NOLOCK) + WHERE cdItemDesc.ItemTypeCode = I.ItemTypeCode + AND cdItemDesc.ItemCode = I.ItemCode + AND cdItemDesc.LangCode = 'TR' + ), SPACE(0))), + UrunAnaGrubu = dbo.HG_Temizlik(ISNULL(( + SELECT AttributeDescription + FROM cdItemAttributeDesc WITH(NOLOCK) + WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode + AND cdItemAttributeDesc.AttributeTypeCode = 1 + AND cdItemAttributeDesc.AttributeCode = ( + SELECT TOP 1 AttributeCode + FROM prItemAttribute WITH(NOLOCK) + WHERE AttributeTypeCode = 1 + AND prItemAttribute.ItemTypeCode = I.ItemTypeCode + AND prItemAttribute.ItemCode = I.ItemCode + ) + AND cdItemAttributeDesc.LangCode = 'TR' + ), SPACE(0))), + UrunAltGrubu = dbo.HG_Temizlik(ISNULL(( + SELECT AttributeDescription + FROM cdItemAttributeDesc WITH(NOLOCK) + WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode + AND cdItemAttributeDesc.AttributeTypeCode = 2 + AND cdItemAttributeDesc.AttributeCode = ( + SELECT TOP 1 AttributeCode + FROM prItemAttribute WITH(NOLOCK) + WHERE AttributeTypeCode = 2 + AND prItemAttribute.ItemTypeCode = I.ItemTypeCode + AND prItemAttribute.ItemCode = I.ItemCode + ) + AND cdItemAttributeDesc.LangCode = 'TR' + ), SPACE(0))), + Kategori = dbo.HG_Temizlik(ISNULL(( + SELECT AttributeDescription + FROM cdItemAttributeDesc WITH(NOLOCK) + WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode + AND cdItemAttributeDesc.AttributeTypeCode = 42 + AND cdItemAttributeDesc.AttributeCode = ( + SELECT TOP 1 AttributeCode + FROM prItemAttribute WITH(NOLOCK) + WHERE AttributeTypeCode = 42 + AND prItemAttribute.ItemTypeCode = I.ItemTypeCode + AND prItemAttribute.ItemCode = I.ItemCode + ) + AND cdItemAttributeDesc.LangCode = 'TR' + ), SPACE(0))), + UrunIlkGrubu = dbo.HG_Temizlik(ISNULL(( + SELECT AttributeDescription + FROM cdItemAttributeDesc WITH(NOLOCK) + WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode + AND cdItemAttributeDesc.AttributeTypeCode = 44 + AND cdItemAttributeDesc.AttributeCode = ( + SELECT TOP 1 AttributeCode + FROM prItemAttribute WITH(NOLOCK) + WHERE AttributeTypeCode = 44 + AND prItemAttribute.ItemTypeCode = I.ItemTypeCode + AND prItemAttribute.ItemCode = I.ItemCode + ) + AND cdItemAttributeDesc.LangCode = 'TR' + ), SPACE(0))), + AskiliYan = dbo.HG_Temizlik(ISNULL(( + SELECT TOP 1 ProductAtt45 + FROM ProductAttributesFilter WITH(NOLOCK) + WHERE ProductAttributesFilter.ItemCode = I.ItemCode + ), SPACE(0))), + ChannelCode = CASE + WHEN I.ProcessCode IN ('R') THEN dbo.HG_Temizlik(ISNULL(( + SELECT OfficeDescription + FROM cdOfficeDesc WITH(NOLOCK) + WHERE cdOfficeDesc.OfficeCode = I.OfficeCode + AND cdOfficeDesc.LangCode = 'TR' + ), SPACE(0))) + ELSE dbo.HG_Temizlik(ISNULL(( + SELECT AttributeDescription + FROM cdCurrAccAttributeDesc WITH(NOLOCK) + WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3 + AND AttributeTypeCode = 1 + AND CAF.CustomerAtt01 = AttributeCode + AND cdCurrAccAttributeDesc.LangCode = 'TR' + ), SPACE(0))) + END, + CustomerCountry = dbo.HG_Temizlik(ISNULL(( + SELECT AttributeDescription + FROM cdCurrAccAttributeDesc WITH(NOLOCK) + WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3 + AND AttributeTypeCode = 5 + AND CAF.CustomerAtt05 = AttributeCode + AND cdCurrAccAttributeDesc.LangCode = 'TR' + ), SPACE(0))), + CustomerSegment = dbo.HG_Temizlik(ISNULL(( + SELECT AttributeDescription + FROM cdCurrAccAttributeDesc WITH(NOLOCK) + WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3 + AND AttributeTypeCode = 11 + AND CAF.CustomerAtt11 = AttributeCode + AND cdCurrAccAttributeDesc.LangCode = 'TR' + ), SPACE(0))), + CustomerCode = CASE + WHEN I.ProcessCode = 'R' THEN dbo.HG_Temizlik(ISNULL(I.StoreCode, '')) + ELSE dbo.HG_Temizlik(ISNULL(I.CurrAccCode, '')) + END, + CustomerDescription = CASE + WHEN I.ProcessCode = 'R' THEN dbo.HG_Temizlik(ISNULL(( + SELECT CurrAccDescription + FROM cdCurrAccDesc WITH(NOLOCK) + WHERE cdCurrAccDesc.CurrAccTypeCode = 5 + AND cdCurrAccDesc.CurrAccCode = I.StoreCode + AND cdCurrAccDesc.LangCode = 'TR' + ), SPACE(0))) + ELSE dbo.HG_Temizlik(ISNULL(( + SELECT CurrAccDescription + FROM cdCurrAccDesc WITH(NOLOCK) + WHERE cdCurrAccDesc.CurrAccTypeCode = I.CurrAccTypeCode + AND cdCurrAccDesc.CurrAccCode = I.CurrAccCode + AND cdCurrAccDesc.LangCode = 'TR' + ), SPACE(0))) + END, + Qty1 = ISNULL(I.Qty1, 0), + TLAmount = ISNULL(I.Doc_Amount, 0) * ISNULL(I.Loc_ExchangeRate, 0), + USDAmount = (ISNULL(I.Doc_Amount, 0) * ISNULL(I.Loc_ExchangeRate, 0)) / ISNULL(( + SELECT TOP 1 Rate + FROM AllExchangeRates WITH(NOLOCK) + WHERE CurrencyCode = 'USD' + AND RelationCurrencyCode = 'TRY' + AND ExchangeTypeCode = 6 + AND Rate > 1 + ORDER BY ABS(DATEDIFF(DAY, AllExchangeRates.Date, I.InvoiceDate)) ASC + ), 1), + RN = ROW_NUMBER() OVER ( + PARTITION BY I.InvoiceLineID + ORDER BY I.InvoiceDate DESC, I.InvoiceNumber + ) + FROM AllInvoicesWithAttributes I WITH(NOLOCK) + LEFT OUTER JOIN CustomerAttributesFilter CAF WITH(NOLOCK) + ON CAF.CurrAccTypeCode = I.CurrAccTypeCode + AND CAF.CurrAccCode = I.CurrAccCode + WHERE I.InvoiceDate >= @p1 + AND I.InvoiceDate < DATEADD(DAY, 1, @p2) + AND I.ItemTypeCode = 1 + AND (I.ItemCode LIKE 'S%' OR I.ItemCode LIKE 'O%' OR I.ItemCode LIKE 'X%' OR I.ItemCode LIKE 'N%') + AND ( + (I.CompanyCode = 1 AND I.ATAtt01 IN (1,2) AND I.ProcessCode IN ('WS','R')) + OR (I.CompanyCode = 4 AND I.ProcessCode = 'R') + ) + AND EXISTS ( + SELECT 1 + FROM trDebitHeader DH WITH(NOLOCK) + INNER JOIN trDebitLine DL WITH(NOLOCK) + ON DL.DebitHeaderID = DH.DebitHeaderID + WHERE DH.ApplicationCode = 'Invoi' + AND DH.ApplicationID = I.InvoiceHeaderID + ) +) +SELECT + SalesDate, + ProductCode, + ColorCode, + YakaKodu, + ItemDescription = MAX(ItemDescription), + Kategori = MAX(Kategori), + Seri = MAX(Kategori), + YasGrubu = MAX(UrunIlkGrubu), + AskiliYan = MAX(AskiliYan), + UrunIlkGrubu = MAX(UrunIlkGrubu), + UrunAnaGrubu = MAX(UrunAnaGrubu), + UrunAltGrubu = MAX(UrunAltGrubu), + MarketKey = CONCAT(MAX(Kategori), '|', MAX(UrunIlkGrubu), '|', MAX(AskiliYan), '|', MAX(UrunAnaGrubu), '|', MAX(UrunAltGrubu), '|', ChannelCode), + ChannelCode, + CustomerCountry = ISNULL(NULLIF(CustomerCountry, ''), '-'), + CustomerSegment = ISNULL(NULLIF(CustomerSegment, ''), '-'), + CustomerCode = ISNULL(NULLIF(CustomerCode, ''), '-'), + CustomerDescription = MAX(ISNULL(NULLIF(CustomerDescription, ''), '-')), + SalesQty = ISNULL(SUM(Qty1), 0), + SalesTL = ISNULL(SUM(TLAmount), 0), + SalesUSD = ISNULL(SUM(USDAmount), 0), + AvgPriceUSD = ISNULL(SUM(USDAmount) / NULLIF(SUM(Qty1), 0), 0), + InvoiceLineCount = COUNT(DISTINCT InvoiceLineID), + InvoiceCount = COUNT(DISTINCT InvoiceHeaderID), + CustomerCount = COUNT(DISTINCT CustomerCode), + LastRefNumber = MAX(InvoiceNumber) +FROM SalesLines +WHERE RN = 1 +GROUP BY SalesDate, ProductCode, ColorCode, YakaKodu, ChannelCode, CustomerCountry, CustomerSegment, CustomerCode +` +} + +func productPerformanceStockSQL() string { + return ` +;WITH ActiveWarehouses AS ( + SELECT WarehouseCode + FROM (VALUES + ('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'), + ('100'),('1-0-28'),('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'), + ('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'),('1-0-33'),('101'), + ('1-014'),('1-0-49'),('1-0-36') + ) W(WarehouseCode) +), +Raw AS ( + SELECT + ProductCode = LTRIM(RTRIM(S.ItemCode)), + ColorCode = LTRIM(RTRIM(ISNULL(S.ColorCode, ''))), + YakaKodu = LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))), + MovementDate = CAST(S.DocumentDate AS date), + ProcessCode = CASE + WHEN LTRIM(RTRIM(ISNULL(S.ProcessCode, ''))) <> '' THEN LTRIM(RTRIM(S.ProcessCode)) + ELSE LTRIM(RTRIM(ISNULL(S.InnerProcessCode, ''))) + END, + InQty = SUM(S.In_Qty1), + OutQty = SUM(S.Out_Qty1), + NetQty = SUM(S.InventoryQty1) + FROM StockWithCost S WITH(NOLOCK) + INNER JOIN ActiveWarehouses W + ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)) + WHERE S.ItemTypeCode = 1 + AND (S.ItemCode LIKE 'S%' OR S.ItemCode LIKE 'O%' OR S.ItemCode LIKE 'X%' OR S.ItemCode LIKE 'N%') + AND S.DocumentDate < DATEADD(DAY, 1, @p2) + GROUP BY + S.ItemCode, + S.ColorCode, + S.ItemDim2Code, + CAST(S.DocumentDate AS date), + CASE + WHEN LTRIM(RTRIM(ISNULL(S.ProcessCode, ''))) <> '' THEN LTRIM(RTRIM(S.ProcessCode)) + ELSE LTRIM(RTRIM(ISNULL(S.InnerProcessCode, ''))) + END +), +Opening AS ( + SELECT + StockDate = @p1, + ProductCode, + ColorCode, + YakaKodu, + NetQty = SUM(NetQty), + InQty = CAST(0 AS decimal(18,4)), + OutQty = CAST(0 AS decimal(18,4)), + KpiInQty = CAST(0 AS decimal(18,4)), + KpiOutQty = CAST(0 AS decimal(18,4)), + SalesMovementQty = CAST(0 AS decimal(18,4)), + ProductionInQty = CAST(0 AS decimal(18,4)), + PurchaseInQty = CAST(0 AS decimal(18,4)), + ConsumptionOutQty = CAST(0 AS decimal(18,4)), + CountDiffQty = CAST(0 AS decimal(18,4)) + FROM Raw + WHERE MovementDate < @p1 + GROUP BY ProductCode, ColorCode, YakaKodu +), +Daily AS ( + SELECT + StockDate = MovementDate, + ProductCode, + ColorCode, + YakaKodu, + NetQty = SUM(NetQty), + InQty = SUM(InQty), + OutQty = SUM(OutQty), + KpiInQty = SUM(CASE WHEN ProcessCode IN ('OP','BP','CI') THEN InQty ELSE 0 END), + KpiOutQty = SUM(CASE WHEN ProcessCode IN ('R','WS','OC','CO') THEN OutQty ELSE 0 END), + SalesMovementQty = SUM(CASE WHEN ProcessCode IN ('R','WS') THEN OutQty ELSE 0 END), + ProductionInQty = SUM(CASE WHEN ProcessCode = 'OP' THEN InQty ELSE 0 END), + PurchaseInQty = SUM(CASE WHEN ProcessCode = 'BP' THEN InQty ELSE 0 END), + ConsumptionOutQty = SUM(CASE WHEN ProcessCode = 'OC' THEN OutQty ELSE 0 END), + CountDiffQty = SUM(CASE WHEN ProcessCode IN ('CO','CI') THEN NetQty ELSE 0 END) + FROM Raw + WHERE MovementDate BETWEEN @p1 AND @p2 + GROUP BY MovementDate, ProductCode, ColorCode, YakaKodu +), +Series AS ( + SELECT * FROM Opening + UNION ALL + SELECT * FROM Daily +), +Running AS ( + SELECT + StockDate, + ProductCode, + ColorCode, + YakaKodu, + StockQty = SUM(NetQty) OVER ( + PARTITION BY ProductCode, ColorCode, YakaKodu + ORDER BY StockDate + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ), + InQty, + OutQty, + KpiInQty, + KpiOutQty, + SalesMovementQty, + ProductionInQty, + PurchaseInQty, + ConsumptionOutQty, + CountDiffQty + FROM Series +) +SELECT + StockDate, + ProductCode, + ColorCode, + YakaKodu, + StockQty, + InQty, + OutQty, + KpiInQty, + KpiOutQty, + SalesMovementQty, + ProductionInQty, + PurchaseInQty, + ConsumptionOutQty, + CountDiffQty +FROM Running +WHERE StockDate BETWEEN @p1 AND @p2 +ORDER BY StockDate, ProductCode, ColorCode, YakaKodu +` +} + +func productPerformancePriceSQL() string { + return ` +;WITH ProductCodes AS ( + SELECT DISTINCT ItemCode + FROM trPriceListLine WITH(NOLOCK) + WHERE ItemTypeCode = 1 + AND (ItemCode LIKE 'S%' OR ItemCode LIKE 'O%' OR ItemCode LIKE 'X%' OR ItemCode LIKE 'N%') + UNION + SELECT DISTINCT ItemCode + FROM prItemBasePrice WITH(NOLOCK) + WHERE ItemTypeCode = 1 + AND (ItemCode LIKE 'S%' OR ItemCode LIKE 'O%' OR ItemCode LIKE 'X%' OR ItemCode LIKE 'N%') +), +LatestPrice AS ( + SELECT + p.ItemCode, + DocCurrencyCode = LTRIM(RTRIM(p.DocCurrencyCode)), + p.Price, + rn = ROW_NUMBER() OVER ( + PARTITION BY p.ItemCode, LTRIM(RTRIM(p.DocCurrencyCode)) + ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC + ) + FROM trPriceListLine p WITH(NOLOCK) + INNER JOIN ProductCodes pc ON pc.ItemCode = p.ItemCode + WHERE p.ItemTypeCode = 1 + AND ISNULL(p.IsDisabled, 0) = 0 + AND LTRIM(RTRIM(p.DocCurrencyCode)) IN ('USD', 'TRY') + AND ( + (LTRIM(RTRIM(p.DocCurrencyCode)) = 'USD' AND LTRIM(RTRIM(p.PriceGroupCode)) = 'TM-USD') + OR (LTRIM(RTRIM(p.DocCurrencyCode)) = 'TRY' AND LTRIM(RTRIM(p.PriceGroupCode)) = 'TM-TRY') + ) + AND p.Price > 0 +), +BasePrice AS ( + SELECT + ItemCode, + BasePriceUsd = MAX(CASE WHEN DocCurrencyCode = 'USD' THEN Price END), + BasePriceTry = MAX(CASE WHEN DocCurrencyCode = 'TRY' THEN Price END) + FROM LatestPrice + WHERE rn = 1 + GROUP BY ItemCode +), +Cost AS ( + SELECT + b.ItemCode, + CostPriceUsd = CAST(b.Price AS decimal(18,2)), + LastPricingDate = CAST(b.PriceDate AS date), + rn = ROW_NUMBER() OVER ( + PARTITION BY b.ItemCode + ORDER BY b.PriceDate DESC, b.LastUpdatedDate DESC + ) + FROM prItemBasePrice b WITH(NOLOCK) + INNER JOIN ProductCodes pc ON pc.ItemCode = b.ItemCode + WHERE b.ItemTypeCode = 1 + AND b.BasePriceCode = 1 + AND LTRIM(RTRIM(b.CurrencyCode)) = 'USD' +) +SELECT + pc.ItemCode, + CostPriceUsd = ISNULL(c.CostPriceUsd, 0), + BasePriceUsd = ISNULL(bp.BasePriceUsd, 0), + BasePriceTry = ISNULL(bp.BasePriceTry, 0), + c.LastPricingDate +FROM ProductCodes pc +LEFT JOIN BasePrice bp ON bp.ItemCode = pc.ItemCode +LEFT JOIN Cost c ON c.ItemCode = pc.ItemCode AND c.rn = 1 +` +} + +func productPerformanceKPISQL() string { + return ` +WITH LatestStock AS ( + SELECT DISTINCT ON (product_code, color_code, yaka_kodu) + product_code, color_code, yaka_kodu, stock_qty + FROM mk_product_performance_stock_daily + WHERE stock_date <= $1 + ORDER BY product_code, color_code, yaka_kodu, stock_date DESC +), +SalesAgg AS ( + SELECT + product_code, + color_code, + yaka_kodu, + market_key, + MAX(item_description) AS item_description, + MAX(kategori) AS kategori, + MAX(seri) AS seri, + MAX(yas_grubu) AS yas_grubu, + MAX(askili_yan) AS askili_yan, + MAX(urun_ilk_grubu) AS urun_ilk_grubu, + MAX(urun_ana_grubu) AS urun_ana_grubu, + MAX(urun_alt_grubu) AS urun_alt_grubu, + SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '29 days') AS sales_qty_30d, + SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS sales_qty_90d, + SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '364 days') AS sales_qty_365d, + SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '729 days') AS sales_qty_730d, + SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '29 days') AS sales_usd_30d, + SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS sales_usd_90d, + SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '364 days') AS sales_usd_365d, + SUM(customer_count) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS customer_count_90d, + MAX(sales_date) AS last_sale_date, + MAX(last_ref_number) AS last_ref_number + FROM mk_product_performance_sales_daily + WHERE sales_date >= $1::date - INTERVAL '729 days' + AND sales_date <= $1::date + GROUP BY product_code, color_code, yaka_kodu, market_key +), +Scope AS ( + SELECT + product_code, color_code, yaka_kodu, market_key, + item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, + sales_qty_30d, sales_qty_90d, sales_qty_365d, sales_qty_730d, + sales_usd_30d, sales_usd_90d, sales_usd_365d, + customer_count_90d, last_sale_date, last_ref_number + FROM SalesAgg + UNION ALL + SELECT + ls.product_code, + ls.color_code, + ls.yaka_kodu, + 'STOK' AS market_key, + '' AS item_description, + '' AS kategori, + '' AS seri, + '' AS yas_grubu, + '' AS askili_yan, + '' AS urun_ilk_grubu, + '' AS urun_ana_grubu, + '' AS urun_alt_grubu, + 0 AS sales_qty_30d, + 0 AS sales_qty_90d, + 0 AS sales_qty_365d, + 0 AS sales_qty_730d, + 0 AS sales_usd_30d, + 0 AS sales_usd_90d, + 0 AS sales_usd_365d, + 0 AS customer_count_90d, + NULL::date AS last_sale_date, + '' AS last_ref_number + FROM LatestStock ls + WHERE COALESCE(ls.stock_qty, 0) <> 0 + AND NOT EXISTS ( + SELECT 1 + FROM SalesAgg sa + WHERE sa.product_code = ls.product_code + AND sa.color_code = ls.color_code + AND sa.yaka_kodu = ls.yaka_kodu + ) +), +Base AS ( + SELECT + s.*, + COALESCE(ls.stock_qty, 0) AS stock_qty, + COALESCE(pd.cost_price_usd, 0) AS cost_price_usd, + COALESCE(pd.base_price_usd, 0) AS base_price_usd, + COALESCE(pd.base_price_try, 0) AS base_price_try, + COALESCE(s.sales_qty_90d, 0) / 90.0 AS avg_daily_sales_90d, + 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, + COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_90d, + CASE WHEN COALESCE(s.sales_usd_90d, 0) = 0 THEN 0 + ELSE (COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0))) / NULLIF(s.sales_usd_90d, 0) + END AS gross_margin_90d + FROM Scope s + LEFT JOIN LatestStock ls ON ls.product_code=s.product_code AND ls.color_code=s.color_code AND ls.yaka_kodu=s.yaka_kodu + LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=s.product_code +), +Market AS ( + SELECT + market_key, + AVG(NULLIF(sales_qty_90d, 0)) AS market_avg_sales_qty_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 + FROM Base + GROUP BY market_key +), +Scored AS ( + SELECT + b.*, + CASE WHEN b.avg_daily_sales_90d = 0 THEN 0 ELSE b.stock_qty / NULLIF(b.avg_daily_sales_90d, 0) END AS stock_days_90d, + CASE WHEN COALESCE(m.market_avg_sales_qty_90d, 0) = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(m.market_avg_sales_qty_90d, 0) END AS sales_index_90d, + CASE WHEN COALESCE(m.market_avg_price_usd_90d, 0) = 0 THEN 0 ELSE b.avg_price_usd_90d / NULLIF(m.market_avg_price_usd_90d, 0) END AS price_index_90d, + CASE WHEN COALESCE(m.market_avg_margin_90d, 0) = 0 THEN 0 ELSE b.gross_margin_90d / NULLIF(m.market_avg_margin_90d, 0) END AS margin_index_90d + FROM Base b + LEFT JOIN Market m ON m.market_key=b.market_key +) +INSERT INTO mk_product_performance_kpi_daily ( + kpi_date, product_code, color_code, yaka_kodu, item_description, + kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, + sales_qty_30d, sales_qty_90d, sales_qty_365d, sales_qty_730d, + sales_usd_30d, sales_usd_90d, sales_usd_365d, avg_daily_sales_90d, + stock_days_90d, avg_price_usd_90d, cost_price_usd, base_price_usd, base_price_try, + gross_profit_usd_90d, gross_margin_90d, customer_count_90d, + sales_index_90d, price_index_90d, margin_index_90d, performance_score, + performance_bucket, recommendation, last_sale_date, last_ref_number, updated_at +) +SELECT + $1::date, + product_code, color_code, yaka_kodu, item_description, + kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, + COALESCE(sales_qty_30d, 0), COALESCE(sales_qty_90d, 0), COALESCE(sales_qty_365d, 0), COALESCE(sales_qty_730d, 0), + COALESCE(sales_usd_30d, 0), COALESCE(sales_usd_90d, 0), COALESCE(sales_usd_365d, 0), COALESCE(avg_daily_sales_90d, 0), + COALESCE(stock_days_90d, 0), COALESCE(avg_price_usd_90d, 0), COALESCE(cost_price_usd, 0), COALESCE(base_price_usd, 0), COALESCE(base_price_try, 0), + COALESCE(gross_profit_usd_90d, 0), COALESCE(gross_margin_90d, 0), COALESCE(customer_count_90d, 0), + COALESCE(sales_index_90d, 0), COALESCE(price_index_90d, 0), COALESCE(margin_index_90d, 0), + ROUND( + LEAST(40, COALESCE(sales_index_90d, 0) * 20) + + LEAST(25, GREATEST(COALESCE(gross_margin_90d, 0), 0) * 50) + + LEAST(20, COALESCE(customer_count_90d, 0) * 2) + + CASE WHEN COALESCE(stock_days_90d, 0) BETWEEN 10 AND 90 THEN 15 WHEN COALESCE(stock_days_90d, 0) > 180 THEN -10 ELSE 0 END + , 4) AS performance_score, + CASE + WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'YILDIZ_URUN' + WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'STOKSUZ_TALEP' + WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'STOK_RISKI' + WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'FIYAT_BASKISI' + WHEN COALESCE(price_index_90d,0) < 0.90 AND COALESCE(sales_index_90d,0) > 1.00 THEN 'FIYAT_FIRSATI' + ELSE 'TAKIP' + END AS performance_bucket, + CASE + WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'Piyasa üstü satış ve iyi marj: stok ve fiyat gücü takip edilmeli.' + WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: üretim/satın alma planına alınmalı.' + WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'Stok yüksek, satış piyasa altı: kampanya veya fiyat kontrolü gerekli.' + WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa üstünde ve satış zayıf: fiyat revizyonu değerlendirilmeli.' + WHEN COALESCE(price_index_90d,0) < 0.90 AND COALESCE(sales_index_90d,0) > 1.00 THEN 'Satış güçlü, fiyat piyasa altı: taban fiyat artışı değerlendirilebilir.' + ELSE 'Düzenli takip.' + END AS recommendation, + last_sale_date, + COALESCE(last_ref_number, ''), + now() +FROM Scored +` +} diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go new file mode 100644 index 0000000..069a0c9 --- /dev/null +++ b/svc/routes/product_performance.go @@ -0,0 +1,179 @@ +package routes + +import ( + "bssapp-backend/queries" + "bssapp-backend/utils" + "context" + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" +) + +func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) + defer cancel() + + f := queries.ProductPerformanceFilters{ + Search: strings.TrimSpace(r.URL.Query().Get("q")), + ProductCode: strings.TrimSpace(r.URL.Query().Get("product_code")), + MarketKey: strings.TrimSpace(r.URL.Query().Get("market_key")), + Kategori: strings.TrimSpace(r.URL.Query().Get("kategori")), + Seri: strings.TrimSpace(r.URL.Query().Get("seri")), + Bucket: strings.TrimSpace(r.URL.Query().Get("bucket")), + Limit: intQuery(r, "limit", 100), + Page: intQuery(r, "page", 1), + } + rows, total, err := queries.ListProductPerformance(ctx, pg, f) + if err != nil { + http.Error(w, "urun performans listesi alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("X-Total-Count", strconv.Itoa(total)) + _ = json.NewEncoder(w).Encode(map[string]any{ + "rows": rows, + "total_count": total, + "page": f.Page, + "limit": f.Limit, + }) + } +} + +func GetProductPerformanceSummaryHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second) + defer cancel() + + summary, err := queries.GetProductPerformanceSummary(ctx, pg) + if err != nil { + http.Error(w, "urun performans ozeti alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(summary) + } +} + +func GetProductPerformanceMarketsHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second) + defer cancel() + + rows, err := queries.ListProductPerformanceMarkets(ctx, pg, intQuery(r, "limit", 100)) + if err != nil { + http.Error(w, "piyasa performans ozeti alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +func GetProductPerformanceCountriesHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second) + defer cancel() + + rows, err := queries.ListProductPerformanceCountries(ctx, pg, intQuery(r, "limit", 100)) + if err != nil { + http.Error(w, "ulke performans ozeti alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +func GetProductPerformanceCustomersHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second) + defer cancel() + + rows, err := queries.ListProductPerformanceCustomers( + ctx, + pg, + strings.TrimSpace(r.URL.Query().Get("breakdown")), + intQuery(r, "limit", 100), + ) + if err != nil { + http.Error(w, "musteri performans kirilimi alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +type productPerformanceRefreshPayload struct { + Mode string `json:"mode"` + Stage string `json:"stage"` + ResumeAfter int `json:"resume_after"` + SkipDelete bool `json:"skip_delete"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` +} + +func PostProductPerformanceRefreshHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + var payload productPerformanceRefreshPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + now := time.Now() + startDate := parseDateOrZero(payload.StartDate) + endDate := parseDateOrZero(payload.EndDate) + if endDate.IsZero() { + endDate = now + } + ctx, cancel := context.WithTimeout(r.Context(), 4*time.Hour) + defer cancel() + result, err := queries.RefreshProductPerformance(ctx, pg, queries.ProductPerformanceRefreshRequest{ + Mode: payload.Mode, + Stage: payload.Stage, + ResumeAfter: payload.ResumeAfter, + SkipDelete: payload.SkipDelete, + StartDate: startDate, + EndDate: endDate, + }) + if err != nil { + http.Error(w, "urun performans refresh hatasi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(result) + } +} + +func intQuery(r *http.Request, key string, fallback int) int { + raw := strings.TrimSpace(r.URL.Query().Get(key)) + if raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return fallback + } + return n +} + +func parseDateOrZero(raw string) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{} + } + t, err := time.Parse("2006-01-02", raw) + if err != nil { + return time.Time{} + } + return t +} diff --git a/ui/.quasar/prod-spa/app.js b/ui/.quasar/prod-spa/app.js deleted file mode 100644 index caeaac1..0000000 --- a/ui/.quasar/prod-spa/app.js +++ /dev/null @@ -1,75 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - - - - -import { Quasar } from 'quasar' -import { markRaw } from 'vue' -import RootComponent from 'app/src/App.vue' - -import createStore from 'app/src/stores/index' -import createRouter from 'app/src/router/index' - - - - - -export default async function (createAppFn, quasarUserOptions) { - - - // Create the app instance. - // Here we inject into it the Quasar UI, the router & possibly the store. - const app = createAppFn(RootComponent) - - - - app.use(Quasar, quasarUserOptions) - - - - - const store = typeof createStore === 'function' - ? await createStore({}) - : createStore - - - app.use(store) - - - - - - const router = markRaw( - typeof createRouter === 'function' - ? await createRouter({store}) - : createRouter - ) - - - // make router instance available in store - - store.use(({ store }) => { store.router = router }) - - - - // Expose the app, the router and the store. - // Note that we are not mounting the app here, since bootstrapping will be - // different depending on whether we are in a browser or on the server. - return { - app, - store, - router - } -} diff --git a/ui/.quasar/prod-spa/client-entry.js b/ui/.quasar/prod-spa/client-entry.js deleted file mode 100644 index 5223e2b..0000000 --- a/ui/.quasar/prod-spa/client-entry.js +++ /dev/null @@ -1,158 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - -import { createApp } from 'vue' - - - - - - - -import '@quasar/extras/roboto-font/roboto-font.css' - -import '@quasar/extras/material-icons/material-icons.css' - - - - -// We load Quasar stylesheet file -import 'quasar/dist/quasar.sass' - - - - -import 'src/css/app.css' - - -import createQuasarApp from './app.js' -import quasarUserOptions from './quasar-user-options.js' - - - - - - - - -const publicPath = `/` - - -async function start ({ - app, - router - , store -}, bootFiles) { - - let hasRedirected = false - const getRedirectUrl = url => { - try { return router.resolve(url).href } - catch (err) {} - - return Object(url) === url - ? null - : url - } - const redirect = url => { - hasRedirected = true - - if (typeof url === 'string' && /^https?:\/\//.test(url)) { - window.location.href = url - return - } - - const href = getRedirectUrl(url) - - // continue if we didn't fail to resolve the url - if (href !== null) { - window.location.href = href - window.location.reload() - } - } - - const urlPath = window.location.href.replace(window.location.origin, '') - - for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) { - try { - await bootFiles[i]({ - app, - router, - store, - ssrContext: null, - redirect, - urlPath, - publicPath - }) - } - catch (err) { - if (err && err.url) { - redirect(err.url) - return - } - - console.error('[Quasar] boot error:', err) - return - } - } - - if (hasRedirected === true) return - - - app.use(router) - - - - - - - app.mount('#q-app') - - - -} - -createQuasarApp(createApp, quasarUserOptions) - - .then(app => { - // eventually remove this when Cordova/Capacitor/Electron support becomes old - const [ method, mapFn ] = Promise.allSettled !== void 0 - ? [ - 'allSettled', - bootFiles => bootFiles.map(result => { - if (result.status === 'rejected') { - console.error('[Quasar] boot error:', result.reason) - return - } - return result.value.default - }) - ] - : [ - 'all', - bootFiles => bootFiles.map(entry => entry.default) - ] - - return Promise[ method ]([ - - import(/* webpackMode: "eager" */ 'boot/dayjs'), - - import(/* webpackMode: "eager" */ 'boot/locale'), - - import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard') - - ]).then(bootFiles => { - const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function') - start(app, boot) - }) - }) - diff --git a/ui/.quasar/prod-spa/client-prefetch.js b/ui/.quasar/prod-spa/client-prefetch.js deleted file mode 100644 index 9bbe3c5..0000000 --- a/ui/.quasar/prod-spa/client-prefetch.js +++ /dev/null @@ -1,116 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - - -import App from 'app/src/App.vue' -let appPrefetch = typeof App.preFetch === 'function' - ? App.preFetch - : ( - // Class components return the component options (and the preFetch hook) inside __c property - App.__c !== void 0 && typeof App.__c.preFetch === 'function' - ? App.__c.preFetch - : false - ) - - -function getMatchedComponents (to, router) { - const route = to - ? (to.matched ? to : router.resolve(to).route) - : router.currentRoute.value - - if (!route) { return [] } - - const matched = route.matched.filter(m => m.components !== void 0) - - if (matched.length === 0) { return [] } - - return Array.prototype.concat.apply([], matched.map(m => { - return Object.keys(m.components).map(key => { - const comp = m.components[key] - return { - path: m.path, - c: comp - } - }) - })) -} - -export function addPreFetchHooks ({ router, store, publicPath }) { - // Add router hook for handling preFetch. - // Doing it after initial route is resolved so that we don't double-fetch - // the data that we already have. Using router.beforeResolve() so that all - // async components are resolved. - router.beforeResolve((to, from, next) => { - const - urlPath = window.location.href.replace(window.location.origin, ''), - matched = getMatchedComponents(to, router), - prevMatched = getMatchedComponents(from, router) - - let diffed = false - const preFetchList = matched - .filter((m, i) => { - return diffed || (diffed = ( - !prevMatched[i] || - prevMatched[i].c !== m.c || - m.path.indexOf('/:') > -1 // does it has params? - )) - }) - .filter(m => m.c !== void 0 && ( - typeof m.c.preFetch === 'function' - // Class components return the component options (and the preFetch hook) inside __c property - || (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function') - )) - .map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch) - - - if (appPrefetch !== false) { - preFetchList.unshift(appPrefetch) - appPrefetch = false - } - - - if (preFetchList.length === 0) { - return next() - } - - let hasRedirected = false - const redirect = url => { - hasRedirected = true - next(url) - } - const proceed = () => { - - if (hasRedirected === false) { next() } - } - - - - preFetchList.reduce( - (promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({ - store, - currentRoute: to, - previousRoute: from, - redirect, - urlPath, - publicPath - })), - Promise.resolve() - ) - .then(proceed) - .catch(e => { - console.error(e) - proceed() - }) - }) -} diff --git a/ui/.quasar/prod-spa/quasar-user-options.js b/ui/.quasar/prod-spa/quasar-user-options.js deleted file mode 100644 index ac1dae3..0000000 --- a/ui/.quasar/prod-spa/quasar-user-options.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - -import lang from 'quasar/lang/tr.js' - - - -import {Loading,Dialog,Notify} from 'quasar' - - - -export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} } - diff --git a/ui/quasar.config.js.temporary.compiled.1782152715292.mjs b/ui/quasar.config.js.temporary.compiled.1782749209942.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1782152715292.mjs rename to ui/quasar.config.js.temporary.compiled.1782749209942.mjs diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index 530770f..7d334b3 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -365,6 +365,11 @@ const menuItems = [ to: '/app/pricing/product-pricing', permission: 'pricing:view' }, + { + label: 'Ürün Performans ve Karlılık Analizi', + to: '/app/pricing/product-performance-profitability', + permission: 'pricing:view' + }, { label: 'Toptan Kampanya Yönetimi', to: '/app/pricing/wholesale-campaigns', diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue new file mode 100644 index 0000000..294675f --- /dev/null +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -0,0 +1,774 @@ + + + + + diff --git a/ui/src/pages/ProductionProductCostingNoCost.vue b/ui/src/pages/ProductionProductCostingNoCost.vue index 152f8c8..8dad93d 100644 --- a/ui/src/pages/ProductionProductCostingNoCost.vue +++ b/ui/src/pages/ProductionProductCostingNoCost.vue @@ -87,26 +87,52 @@
{{ props.col.label }}
- - + +
@@ -241,12 +267,29 @@ function getColumnFilter (name) { if (!columnFilters[name]) { columnFilters[name] = { text: '', - selected: [] + selected: [], + min: '', + max: '' } } return columnFilters[name] } +function isNumericColumn (name) { + return name === 'lMMiktar_G' +} + +function parseNumberFilter (value) { + const raw = String(value ?? '').trim() + if (!raw) return null + const normalized = raw + .replace(/\s/g, '') + .replace(/\./g, '') + .replace(',', '.') + const n = Number(normalized) + return Number.isFinite(n) ? n : null +} + function formatDateTR (value) { const s = String(value || '').trim() if (!s) return '' @@ -264,10 +307,19 @@ const rows = computed(() => { const cf = getColumnFilter(col.name) const text = String(cf.text || '').trim().toLowerCase() const selected = Array.isArray(cf.selected) ? cf.selected : [] + const min = parseNumberFilter(cf.min) + const max = parseNumberFilter(cf.max) - if (!text && selected.length === 0) continue + if (!text && selected.length === 0 && min === null && max === null) continue result = result.filter((row) => { + if (isNumericColumn(col.name)) { + const numericValue = Number(row?.[col.name] || 0) + if (min !== null && numericValue < min) return false + if (max !== null && numericValue > max) return false + return true + } + const value = getColumnComparableValue(row, col.name) const valueLC = value.toLowerCase() @@ -310,13 +362,18 @@ function getColumnDistinctOptions (colName) { function isColumnFilterActive (name) { const cf = getColumnFilter(name) - return !!String(cf.text || '').trim() || (Array.isArray(cf.selected) && cf.selected.length > 0) + return !!String(cf.text || '').trim() || + (Array.isArray(cf.selected) && cf.selected.length > 0) || + parseNumberFilter(cf.min) !== null || + parseNumberFilter(cf.max) !== null } function clearColumnFilter (name) { const cf = getColumnFilter(name) cf.text = '' cf.selected = [] + cf.min = '' + cf.max = '' } function clearAllColumnFilters () { diff --git a/ui/src/router/routes.js b/ui/src/router/routes.js index ba6df47..dae7472 100644 --- a/ui/src/router/routes.js +++ b/ui/src/router/routes.js @@ -394,6 +394,12 @@ const routes = [ component: () => import('pages/ProductPricing.vue'), meta: { permission: 'pricing:view' } }, + { + path: 'pricing/product-performance-profitability', + name: 'product-performance-profitability', + component: () => import('pages/ProductPerformanceProfitability.vue'), + meta: { permission: 'pricing:view' } + }, { path: 'pricing/brand-classification', name: 'brand-classification',