ui: add B2B olmayan stok (orphans) page

This commit is contained in:
M_Kececi
2026-07-02 15:33:49 +03:00
parent 810c0e5eff
commit cbb853d433
19 changed files with 3365 additions and 395 deletions
+17
View File
@@ -33,6 +33,23 @@ PDF_FONT_DIR=D:\\baggitekstilas\\software projects\\bssapp\\bssapp\\svc\\fonts
API_HOST=0.0.0.0 API_HOST=0.0.0.0
API_PORT=8080 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_KEY=d055c693-a84e-4594-8aef-a6c05c42623a
AZURE_TRANSLATOR_ENDPOINT=https://api.cognitive.microsofttranslator.com AZURE_TRANSLATOR_ENDPOINT=https://api.cognitive.microsofttranslator.com
@@ -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))
}
+1
View File
@@ -0,0 +1 @@
exit status 0xffffffff
+299
View File
@@ -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
+34
View File
@@ -929,6 +929,36 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "update", "pricing", "update",
wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)), 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, bindV3(r, pgDB,
"/api/pricing/product-series/definitions", "GET", "/api/pricing/product-series/definitions", "GET",
"pricing", "view", "pricing", "view",
@@ -1372,6 +1402,9 @@ func main() {
if err := queries.EnsureProductSeriesAutoInfraTables(pgDB); err != nil { if err := queries.EnsureProductSeriesAutoInfraTables(pgDB); err != nil {
log.Println("product series auto infra bootstrap failed:", err) 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 // ✉️ MAILER INIT
@@ -1398,6 +1431,7 @@ func main() {
startProductPricingFxDeltaScheduler(pgDB) startProductPricingFxDeltaScheduler(pgDB)
startProductPricingFxFullScheduler(pgDB) startProductPricingFxFullScheduler(pgDB)
startProductSeriesAutoSchedulers(pgDB) startProductSeriesAutoSchedulers(pgDB)
startProductPerformanceScheduler(pgDB)
handler := enableCORS( handler := enableCORS(
middlewares.GlobalAuthMiddleware( middlewares.GlobalAuthMiddleware(
+113
View File
@@ -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"`
}
+251
View File
@@ -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
}
}
+963
View File
@@ -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
}
+562
View File
@@ -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
`
}
+179
View File
@@ -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
}
-75
View File
@@ -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 <name>" 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
}
}
-158
View File
@@ -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 <name>" 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)
})
})
-116
View File
@@ -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 <name>" 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()
})
})
}
@@ -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 <name>" 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} }
+5
View File
@@ -365,6 +365,11 @@ const menuItems = [
to: '/app/pricing/product-pricing', to: '/app/pricing/product-pricing',
permission: 'pricing:view' 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', label: 'Toptan Kampanya Yönetimi',
to: '/app/pricing/wholesale-campaigns', to: '/app/pricing/wholesale-campaigns',
@@ -0,0 +1,774 @@
<template>
<q-page class="product-performance-page q-pa-sm">
<div class="row items-center justify-between q-col-gutter-sm q-mb-sm">
<div class="col-12 col-md">
<div class="text-h6">Ürün Performans ve Karlılık Analizi</div>
<div class="text-caption text-grey-7">
Ürün, renk, yaka, piyasa ve müşteri segmenti bazında satış, stok dönüşü ve karlılık KPI'ları.
</div>
</div>
<div class="col-12 col-md-auto row items-center q-gutter-sm">
<q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" @click="reload" />
<q-btn
v-if="canUpdate"
color="primary"
unelevated
icon="sync"
label="Delta Çalıştır"
:loading="refreshing"
@click="runDelta"
/>
</div>
</div>
<div class="row q-col-gutter-sm q-mb-sm">
<div v-for="card in summaryCards" :key="card.key" class="col-6 col-md-2">
<q-card flat bordered class="metric-card">
<q-card-section>
<div class="text-caption text-grey-7">{{ card.label }}</div>
<div class="metric-value">{{ card.value }}</div>
</q-card-section>
</q-card>
</div>
</div>
<q-card flat bordered class="q-mb-sm">
<q-card-section class="row q-col-gutter-sm items-end">
<div class="col-12 col-md-3">
<q-input v-model="filters.q" dense outlined clearable label="Ürün / açıklama ara" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-input v-model="filters.product_code" dense outlined clearable label="Ürün kodu" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-input v-model="filters.kategori" dense outlined clearable label="Kategori" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-input v-model="filters.seri" dense outlined clearable label="Ürün Ana Grubu" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-select
v-model="filters.bucket"
dense
outlined
clearable
emit-value
map-options
label="Durum"
:options="bucketOptions"
/>
</div>
<div class="col-12 col-md-1">
<q-btn class="full-width" color="secondary" outline icon="search" label="Ara" @click="reload" />
</div>
</q-card-section>
</q-card>
<q-tabs
v-model="activeTab"
dense
class="bg-white text-grey-8 q-mb-sm"
active-color="primary"
indicator-color="primary"
align="left"
>
<q-tab name="products" icon="inventory_2" label="Ürün KPI" />
<q-tab name="idle" icon="warning" label="Atıl Stok / Maliyet" />
<q-tab name="markets" icon="query_stats" label="Piyasa Grup Kıyası" />
<q-tab name="countries" icon="public" label="Ülke / Segment" />
<q-tab name="customers" icon="groups" label="Müşteri Kırılımı" />
</q-tabs>
<q-table
v-if="activeTab === 'products'"
flat
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="rows"
:columns="columns"
:loading="loading"
:pagination="pagination"
@request="onRequest"
>
<template #body-cell-image="props">
<q-td :props="props" class="product-image-cell">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<q-img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
fit="contain"
class="product-thumb"
no-spinner
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
<template #body-cell-gross_margin_90d="props">
<q-td :props="props" class="text-right">
{{ formatPercent(props.row.gross_margin_90d) }}
</q-td>
</template>
<template #body-cell-sales_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.sales_usd_90d, 'USD') }}
</q-td>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'idle'"
flat
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="idleRows"
:columns="idleColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
>
<template #body-cell-image="props">
<q-td :props="props" class="product-image-cell">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<q-img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
fit="contain"
class="product-thumb"
no-spinner
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</template>
<template #body-cell-idle_cost_usd="props">
<q-td :props="props" class="text-right text-weight-bold">
{{ formatMoney(props.row.idle_cost_usd, 'USD') }}
</q-td>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'markets'"
flat
bordered
row-key="market_key"
class="performance-table bg-white"
:rows="marketRows"
:columns="marketColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
>
<template #body-cell-stock_cost_value_usd="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.stock_cost_value_usd, 'USD') }}
</q-td>
</template>
<template #body-cell-risk_stock_cost_value_usd="props">
<q-td :props="props" class="text-right text-negative text-weight-bold">
{{ formatMoney(props.row.risk_stock_cost_value_usd, 'USD') }}
</q-td>
</template>
<template #body-cell-avg_gross_margin_90d="props">
<q-td :props="props" class="text-right">
{{ formatPercent(props.row.avg_gross_margin_90d) }}
</q-td>
</template>
</q-table>
<div v-else-if="activeTab === 'customers'">
<q-card flat bordered class="q-mb-sm">
<q-card-section class="row q-col-gutter-sm items-center">
<div class="col-12 col-md-4">
<q-select
v-model="customerBreakdown"
dense
outlined
emit-value
map-options
label="Kırılım"
:options="customerBreakdownOptions"
@update:model-value="reload"
/>
</div>
</q-card-section>
</q-card>
<q-table
flat
bordered
row-key="customer_key"
class="performance-table bg-white"
:rows="customerRows"
:columns="customerColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
>
<template #body-cell-sales_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.sales_usd_90d, 'USD') }}
</q-td>
</template>
<template #body-cell-avg_price_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.avg_price_usd_90d, 'USD') }}
</q-td>
</template>
<template #body-cell-sales_usd_365d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.sales_usd_365d, 'USD') }}
</q-td>
</template>
</q-table>
</div>
<q-table
v-else
flat
bordered
row-key="country_key"
class="performance-table bg-white"
:rows="countryRows"
:columns="countryColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
>
<template #body-cell-sales_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.sales_usd_90d, 'USD') }}
</q-td>
</template>
<template #body-cell-avg_price_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.avg_price_usd_90d, 'USD') }}
</q-td>
</template>
</q-table>
<q-dialog v-model="imageDialog" maximized>
<q-card class="product-image-dialog">
<q-card-section class="row items-center q-pb-sm">
<div>
<div class="text-h6">{{ imageDialogTitle }}</div>
<div class="text-caption text-grey-7">{{ imageDialogSubtitle }}</div>
</div>
<q-space />
<q-btn v-close-popup flat round dense icon="close" />
</q-card-section>
<q-separator />
<q-card-section class="product-image-dialog-body">
<q-carousel
v-if="imageDialogUrls.length"
v-model="imageSlide"
animated
arrows
navigation
infinite
swipeable
class="product-image-carousel"
>
<q-carousel-slide
v-for="(url, idx) in imageDialogUrls"
:key="`${url}-${idx}`"
:name="idx"
class="column no-wrap flex-center"
>
<q-img :src="url" fit="contain" class="product-image-large" />
</q-carousel-slide>
</q-carousel>
<div v-else class="product-image-empty">
<q-icon name="image_not_supported" size="48px" />
<div class="text-subtitle2 q-mt-sm">Foto bulunamadi</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { Notify } from 'quasar'
import api from 'src/services/api'
import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters'
import { usePermissionStore } from 'stores/permissionStore'
const perm = usePermissionStore()
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
const loading = ref(false)
const refreshing = ref(false)
const rows = ref([])
const marketRows = ref([])
const countryRows = ref([])
const customerRows = ref([])
const summary = ref({})
const activeTab = ref('products')
const customerBreakdown = ref('market_customer')
const imageUrlByKey = ref({})
const imageListByKey = ref({})
const imageDialog = ref(false)
const imageDialogUrls = ref([])
const imageDialogTitle = ref('')
const imageDialogSubtitle = ref('')
const imageSlide = ref(0)
const filters = reactive({
q: '',
product_code: '',
kategori: '',
seri: '',
bucket: ''
})
const pagination = ref({
page: 1,
rowsPerPage: 100,
rowsNumber: 0,
sortBy: 'performance_score',
descending: true
})
const bucketOptions = [
{ label: 'Yıldız Ürün', value: 'YILDIZ_URUN' },
{ label: 'Stoksuz Talep', value: 'STOKSUZ_TALEP' },
{ label: 'Stok Riski', value: 'STOK_RISKI' },
{ label: 'Fiyat Baskısı', value: 'FIYAT_BASKISI' },
{ label: 'Fiyat Fırsatı', value: 'FIYAT_FIRSATI' },
{ label: 'Takip', value: 'TAKIP' }
]
const customerBreakdownOptions = [
{ label: 'Piyasa > Müşteri', value: 'market_customer' },
{ label: 'Ülke > Müşteri', value: 'country_customer' },
{ label: 'Piyasa > Ülke > Müşteri', value: 'market_country_customer' }
]
const columns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left' },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' },
{ name: 'gross_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true },
{ name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
{ name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const idleColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left' },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'Maliyet USD', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const marketColumns = [
{ name: 'market_key', label: 'Piyasa Grubu', field: 'market_key', align: 'left', sortable: true },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left' },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'star_count', label: 'Yıldız', field: row => formatNumber(row.star_count, 0), align: 'right', sortable: true },
{ name: 'stock_risk_count', label: 'Risk', field: row => formatNumber(row.stock_risk_count, 0), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'stock_cost_value_usd', label: 'Stok Maliyeti', field: 'stock_cost_value_usd', align: 'right', sortable: true },
{ name: 'risk_stock_cost_value_usd', label: 'Risk Maliyeti', field: 'risk_stock_cost_value_usd', align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: row => formatMoney(row.sales_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_gross_margin_90d', label: 'Ort. Marj', field: 'avg_gross_margin_90d', align: 'right', sortable: true },
{ name: 'avg_stock_days_90d', label: 'Ort. Stok Gün', field: row => formatNumber(row.avg_stock_days_90d, 1), align: 'right', sortable: true }
]
const countryColumns = [
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
{ name: 'customer_segment', label: 'Müşteri Segmenti', field: 'customer_segment', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa Grubu', field: 'market_key', align: 'left' },
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
{ name: 'customer_count_90d', label: 'Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true },
{ name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
{ name: 'sales_qty_365d', label: '365G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_365d', label: '365G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true }
]
const customerColumns = [
{ name: 'market_key', label: 'Piyasa Grubu', field: 'market_key', align: 'left', sortable: true },
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
{ name: 'customer_segment', label: 'Segment', field: 'customer_segment', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
{ name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
{ name: 'sales_qty_365d', label: '365G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_365d', label: '365G USD', field: 'sales_usd_365d', align: 'right', sortable: true },
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true }
]
const summaryCards = computed(() => [
{ key: 'date', label: 'KPI Tarihi', value: summary.value.kpi_date || '-' },
{ key: 'rows', label: 'Satır', value: formatNumber(summary.value.total_rows, 0) },
{ key: 'star', label: 'Yıldız', value: formatNumber(summary.value.star_count, 0) },
{ key: 'risk', label: 'Stok Riski', value: formatNumber(summary.value.stock_risk, 0) },
{ key: 'stock_cost', label: 'Stok Maliyeti', value: formatMoney(summary.value.stock_cost_usd, 'USD') },
{ key: 'risk_cost', label: 'Risk Maliyeti', value: formatMoney(summary.value.risk_cost_usd, 'USD') }
])
const idleRows = computed(() => rows.value
.map(row => ({
...row,
idle_cost_usd: Number(row.stock_qty || 0) * Number(row.cost_price_usd || 0)
}))
.filter(row => Number(row.stock_qty || 0) > 0 && (
row.performance_bucket === 'STOK_RISKI' ||
Number(row.sales_qty_90d || 0) === 0 ||
Number(row.stock_days_90d || 0) > 180
))
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0)))
function normalizeRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return {
...row,
row_key: `${product}|${color}|${yaka}|${market}`
}
}
function productImageKey (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
return `${product}|${color}|${yaka}`
}
function normalizeUploadsPath (storagePath) {
const raw = String(storagePath || '').trim()
if (!raw) return ''
const normalized = raw.replace(/\\/g, '/')
const idx = normalized.toLowerCase().indexOf('/uploads/')
if (idx >= 0) return normalized.slice(idx)
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
return ''
}
function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return ''
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) return contentURL
if (contentURL.startsWith('/')) return `/api${contentURL}`
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
if (thumbURL) return thumbURL
const fullURL = String(item.full_url || item.fullUrl || '').trim()
if (fullURL) return fullURL
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
if (uploadsPath) return uploadsPath
const fileName = String(item.file_name || item.FileName || '').trim()
return fileName ? `/uploads/image/${fileName}` : ''
}
async function fetchProductImagesForRow (row) {
const key = productImageKey(row)
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) {
return imageListByKey.value[key]
}
const code = String(row?.product_code || '').trim()
if (!code) return []
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
try {
const resp = await api.get('/product-images', {
params: {
code,
dim1: color || undefined,
dim3: yaka || undefined
},
timeout: 60000
})
const list = Array.isArray(resp?.data) ? resp.data : []
const urls = list.map(resolveProductImageUrl).filter(Boolean)
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
return urls
} catch {
imageListByKey.value = { ...imageListByKey.value, [key]: [] }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' }
return []
}
}
function getCachedProductImageUrl (row) {
return imageUrlByKey.value[productImageKey(row)] || ''
}
async function primeProductImages (sourceRows) {
const uniqueRows = []
const seen = new Set()
for (const row of sourceRows || []) {
const key = productImageKey(row)
if (!key || seen.has(key)) continue
seen.add(key)
uniqueRows.push(row)
if (uniqueRows.length >= 80) break
}
await Promise.all(uniqueRows.map(row => fetchProductImagesForRow(row)))
}
async function openProductImageDialog (row) {
const urls = await fetchProductImagesForRow(row)
imageDialogUrls.value = urls
imageSlide.value = 0
imageDialogTitle.value = String(row?.product_code || '-')
imageDialogSubtitle.value = [
row?.item_description,
row?.color_code ? `Renk: ${row.color_code}` : '',
row?.yaka_kodu ? `Yaka: ${row.yaka_kodu}` : ''
].filter(Boolean).join(' | ')
imageDialog.value = true
}
async function reload () {
loading.value = true
try {
const [summaryResp, listResp, marketResp, countryResp, customerResp] = await Promise.all([
api.get('/pricing/product-performance/summary', { timeout: 60000 }),
api.get('/pricing/product-performance', {
params: {
page: pagination.value.page,
limit: pagination.value.rowsPerPage,
q: filters.q || undefined,
product_code: filters.product_code || undefined,
kategori: filters.kategori || undefined,
seri: filters.seri || undefined,
bucket: filters.bucket || undefined
},
timeout: 60000
}),
api.get('/pricing/product-performance/markets', { params: { limit: 200 }, timeout: 60000 }),
api.get('/pricing/product-performance/countries', { params: { limit: 200 }, timeout: 60000 }),
api.get('/pricing/product-performance/customers', {
params: { limit: 200, breakdown: customerBreakdown.value },
timeout: 60000
})
])
summary.value = summaryResp?.data || {}
rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(normalizeRow)
marketRows.value = Array.isArray(marketResp?.data) ? marketResp.data : []
countryRows.value = (Array.isArray(countryResp?.data) ? countryResp.data : []).map(row => ({
...row,
country_key: `${row.country || '-'}|${row.customer_segment || '-'}|${row.market_key || '-'}`
}))
customerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(row => ({
...row,
customer_key: `${row.breakdown || '-'}|${row.market_key || '-'}|${row.country || '-'}|${row.customer_segment || '-'}|${row.customer_code || '-'}`
}))
pagination.value.rowsNumber = Number(listResp?.data?.total_count || 0)
void primeProductImages(rows.value)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
} finally {
loading.value = false
}
}
async function runDelta () {
refreshing.value = true
try {
const resp = await api.post('/pricing/product-performance/refresh', { mode: 'delta' }, { timeout: 4 * 60 * 60 * 1000 })
Notify.create({
type: 'positive',
message: `Delta tamamlandı: satış ${resp?.data?.sales_rows || 0}, stok ${resp?.data?.stock_rows || 0}, KPI ${resp?.data?.kpi_rows || 0}`
})
await reload()
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Delta çalıştırılamadı' })
} finally {
refreshing.value = false
}
}
function onRequest (props) {
pagination.value = { ...pagination.value, ...props.pagination }
reload()
}
function bucketLabel (value) {
return bucketOptions.find(x => x.value === value)?.label || value || '-'
}
function bucketColor (value) {
switch (value) {
case 'YILDIZ_URUN': return 'green-7'
case 'STOKSUZ_TALEP': return 'blue-7'
case 'STOK_RISKI': return 'orange-8'
case 'FIYAT_BASKISI': return 'red-7'
case 'FIYAT_FIRSATI': return 'teal-7'
default: return 'grey-7'
}
}
onMounted(reload)
</script>
<style scoped>
.product-performance-page {
background: #f6f7f9;
}
.metric-card {
min-height: 78px;
}
.metric-value {
font-size: 20px;
font-weight: 700;
line-height: 1.25;
margin-top: 4px;
}
.performance-table {
max-height: calc(100vh - 260px);
}
.performance-table :deep(.q-table__middle) {
max-height: calc(100vh - 320px);
}
.performance-table :deep(th) {
position: sticky;
top: 0;
z-index: 1;
background: #fff;
}
.product-image-cell {
width: 72px;
min-width: 72px;
}
.product-thumb-button {
width: 56px;
height: 72px;
border: 1px solid #d9dde3;
border-radius: 6px;
background: #fff;
padding: 2px;
cursor: pointer;
}
.product-thumb,
.product-thumb-placeholder {
width: 50px;
height: 66px;
}
.product-thumb-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #9aa3af;
background: #f3f5f7;
border-radius: 4px;
}
.product-image-dialog {
background: #f6f7f9;
}
.product-image-dialog-body {
height: calc(100vh - 74px);
padding: 12px;
}
.product-image-carousel {
height: 100%;
background: #fff;
border: 1px solid #d9dde3;
border-radius: 8px;
}
.product-image-large {
width: 100%;
height: calc(100vh - 130px);
}
.product-image-empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #7a8390;
background: #fff;
border: 1px solid #d9dde3;
border-radius: 8px;
}
</style>
+80 -23
View File
@@ -87,26 +87,52 @@
<q-menu class="npc-filter-menu" fit> <q-menu class="npc-filter-menu" fit>
<div class="npc-filter-menu-content"> <div class="npc-filter-menu-content">
<div class="text-caption text-weight-bold q-mb-sm">{{ props.col.label }}</div> <div class="text-caption text-weight-bold q-mb-sm">{{ props.col.label }}</div>
<q-input <template v-if="isNumericColumn(props.col.name)">
v-model="getColumnFilter(props.col.name).text" <div class="row q-col-gutter-sm">
dense <div class="col-6">
outlined <q-input
clearable v-model="getColumnFilter(props.col.name).min"
label="Icerir" dense
/> outlined
<q-select clearable
v-model="getColumnFilter(props.col.name).selected" inputmode="decimal"
class="q-mt-sm" label="Min"
dense />
outlined </div>
multiple <div class="col-6">
use-chips <q-input
use-input v-model="getColumnFilter(props.col.name).max"
emit-value dense
map-options outlined
:options="getColumnDistinctOptions(props.col.name)" clearable
label="Deger Sec" inputmode="decimal"
/> label="Max"
/>
</div>
</div>
</template>
<template v-else>
<q-input
v-model="getColumnFilter(props.col.name).text"
dense
outlined
clearable
label="Icerir"
/>
<q-select
v-model="getColumnFilter(props.col.name).selected"
class="q-mt-sm"
dense
outlined
multiple
use-chips
use-input
emit-value
map-options
:options="getColumnDistinctOptions(props.col.name)"
label="Deger Sec"
/>
</template>
<div class="row justify-end q-gutter-sm q-mt-sm"> <div class="row justify-end q-gutter-sm q-mt-sm">
<q-btn dense flat color="grey-7" label="Temizle" @click="clearColumnFilter(props.col.name)" /> <q-btn dense flat color="grey-7" label="Temizle" @click="clearColumnFilter(props.col.name)" />
</div> </div>
@@ -241,12 +267,29 @@ function getColumnFilter (name) {
if (!columnFilters[name]) { if (!columnFilters[name]) {
columnFilters[name] = { columnFilters[name] = {
text: '', text: '',
selected: [] selected: [],
min: '',
max: ''
} }
} }
return columnFilters[name] 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) { function formatDateTR (value) {
const s = String(value || '').trim() const s = String(value || '').trim()
if (!s) return '' if (!s) return ''
@@ -264,10 +307,19 @@ const rows = computed(() => {
const cf = getColumnFilter(col.name) const cf = getColumnFilter(col.name)
const text = String(cf.text || '').trim().toLowerCase() const text = String(cf.text || '').trim().toLowerCase()
const selected = Array.isArray(cf.selected) ? cf.selected : [] 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) => { 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 value = getColumnComparableValue(row, col.name)
const valueLC = value.toLowerCase() const valueLC = value.toLowerCase()
@@ -310,13 +362,18 @@ function getColumnDistinctOptions (colName) {
function isColumnFilterActive (name) { function isColumnFilterActive (name) {
const cf = getColumnFilter(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) { function clearColumnFilter (name) {
const cf = getColumnFilter(name) const cf = getColumnFilter(name)
cf.text = '' cf.text = ''
cf.selected = [] cf.selected = []
cf.min = ''
cf.max = ''
} }
function clearAllColumnFilters () { function clearAllColumnFilters () {
+6
View File
@@ -394,6 +394,12 @@ const routes = [
component: () => import('pages/ProductPricing.vue'), component: () => import('pages/ProductPricing.vue'),
meta: { permission: 'pricing:view' } 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', path: 'pricing/brand-classification',
name: 'brand-classification', name: 'brand-classification',