ui: add B2B olmayan stok (orphans) page
This commit is contained in:
@@ -33,6 +33,23 @@ PDF_FONT_DIR=D:\\baggitekstilas\\software projects\\bssapp\\bssapp\\svc\\fonts
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8080
|
||||
|
||||
# ===============================
|
||||
# PRODUCT PERFORMANCE CACHE
|
||||
# ===============================
|
||||
# MSSQL yogun kullanildigi icin uygulama acilisinda cache isi calismaz.
|
||||
# Ilk full refresh 19:00 sonrasi manuel calistirilmali.
|
||||
PRODUCT_PERFORMANCE_ENABLED=true
|
||||
PRODUCT_PERFORMANCE_RUN_ON_STARTUP=false
|
||||
PRODUCT_PERFORMANCE_DELTA_DAYS=45
|
||||
PRODUCT_PERFORMANCE_DELTA_HHMM=02:00
|
||||
PRODUCT_PERFORMANCE_DELTA_TIMEOUT_HOURS=6
|
||||
PRODUCT_PERFORMANCE_FULL_ENABLED=true
|
||||
PRODUCT_PERFORMANCE_FULL_WEEKDAY=saturday
|
||||
PRODUCT_PERFORMANCE_FULL_HHMM=16:00
|
||||
PRODUCT_PERFORMANCE_FULL_START_DATE=2022-01-01
|
||||
PRODUCT_PERFORMANCE_FULL_TIMEOUT_HOURS=18
|
||||
PRODUCT_PERFORMANCE_RESUME_ON_STARTUP=true
|
||||
|
||||
|
||||
AZURE_TRANSLATOR_KEY=d055c693-a84e-4594-8aef-a6c05c42623a
|
||||
AZURE_TRANSLATOR_ENDPOINT=https://api.cognitive.microsofttranslator.com
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
exit status 0xffffffff
|
||||
@@ -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
@@ -929,6 +929,36 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/summary", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceSummaryHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/markets", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceMarketsHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/countries", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceCountriesHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/customers", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceCustomersHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/refresh", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostProductPerformanceRefreshHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-series/definitions", "GET",
|
||||
"pricing", "view",
|
||||
@@ -1372,6 +1402,9 @@ func main() {
|
||||
if err := queries.EnsureProductSeriesAutoInfraTables(pgDB); err != nil {
|
||||
log.Println("product series auto infra bootstrap failed:", err)
|
||||
}
|
||||
if err := queries.EnsureProductPerformanceTables(pgDB); err != nil {
|
||||
log.Println("product performance infra bootstrap failed:", err)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// ✉️ MAILER INIT
|
||||
@@ -1398,6 +1431,7 @@ func main() {
|
||||
startProductPricingFxDeltaScheduler(pgDB)
|
||||
startProductPricingFxFullScheduler(pgDB)
|
||||
startProductSeriesAutoSchedulers(pgDB)
|
||||
startProductPerformanceScheduler(pgDB)
|
||||
|
||||
handler := enableCORS(
|
||||
middlewares.GlobalAuthMiddleware(
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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} }
|
||||
|
||||
@@ -365,6 +365,11 @@ const menuItems = [
|
||||
to: '/app/pricing/product-pricing',
|
||||
permission: 'pricing:view'
|
||||
},
|
||||
{
|
||||
label: 'Ürün Performans ve Karlılık Analizi',
|
||||
to: '/app/pricing/product-performance-profitability',
|
||||
permission: 'pricing:view'
|
||||
},
|
||||
{
|
||||
label: 'Toptan Kampanya Yönetimi',
|
||||
to: '/app/pricing/wholesale-campaigns',
|
||||
|
||||
@@ -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>
|
||||
@@ -87,6 +87,31 @@
|
||||
<q-menu class="npc-filter-menu" fit>
|
||||
<div class="npc-filter-menu-content">
|
||||
<div class="text-caption text-weight-bold q-mb-sm">{{ props.col.label }}</div>
|
||||
<template v-if="isNumericColumn(props.col.name)">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
v-model="getColumnFilter(props.col.name).min"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
inputmode="decimal"
|
||||
label="Min"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
v-model="getColumnFilter(props.col.name).max"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
inputmode="decimal"
|
||||
label="Max"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<q-input
|
||||
v-model="getColumnFilter(props.col.name).text"
|
||||
dense
|
||||
@@ -107,6 +132,7 @@
|
||||
:options="getColumnDistinctOptions(props.col.name)"
|
||||
label="Deger Sec"
|
||||
/>
|
||||
</template>
|
||||
<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)" />
|
||||
</div>
|
||||
@@ -241,12 +267,29 @@ function getColumnFilter (name) {
|
||||
if (!columnFilters[name]) {
|
||||
columnFilters[name] = {
|
||||
text: '',
|
||||
selected: []
|
||||
selected: [],
|
||||
min: '',
|
||||
max: ''
|
||||
}
|
||||
}
|
||||
return columnFilters[name]
|
||||
}
|
||||
|
||||
function isNumericColumn (name) {
|
||||
return name === 'lMMiktar_G'
|
||||
}
|
||||
|
||||
function parseNumberFilter (value) {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return null
|
||||
const normalized = raw
|
||||
.replace(/\s/g, '')
|
||||
.replace(/\./g, '')
|
||||
.replace(',', '.')
|
||||
const n = Number(normalized)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function formatDateTR (value) {
|
||||
const s = String(value || '').trim()
|
||||
if (!s) return ''
|
||||
@@ -264,10 +307,19 @@ const rows = computed(() => {
|
||||
const cf = getColumnFilter(col.name)
|
||||
const text = String(cf.text || '').trim().toLowerCase()
|
||||
const selected = Array.isArray(cf.selected) ? cf.selected : []
|
||||
const min = parseNumberFilter(cf.min)
|
||||
const max = parseNumberFilter(cf.max)
|
||||
|
||||
if (!text && selected.length === 0) continue
|
||||
if (!text && selected.length === 0 && min === null && max === null) continue
|
||||
|
||||
result = result.filter((row) => {
|
||||
if (isNumericColumn(col.name)) {
|
||||
const numericValue = Number(row?.[col.name] || 0)
|
||||
if (min !== null && numericValue < min) return false
|
||||
if (max !== null && numericValue > max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const value = getColumnComparableValue(row, col.name)
|
||||
const valueLC = value.toLowerCase()
|
||||
|
||||
@@ -310,13 +362,18 @@ function getColumnDistinctOptions (colName) {
|
||||
|
||||
function isColumnFilterActive (name) {
|
||||
const cf = getColumnFilter(name)
|
||||
return !!String(cf.text || '').trim() || (Array.isArray(cf.selected) && cf.selected.length > 0)
|
||||
return !!String(cf.text || '').trim() ||
|
||||
(Array.isArray(cf.selected) && cf.selected.length > 0) ||
|
||||
parseNumberFilter(cf.min) !== null ||
|
||||
parseNumberFilter(cf.max) !== null
|
||||
}
|
||||
|
||||
function clearColumnFilter (name) {
|
||||
const cf = getColumnFilter(name)
|
||||
cf.text = ''
|
||||
cf.selected = []
|
||||
cf.min = ''
|
||||
cf.max = ''
|
||||
}
|
||||
|
||||
function clearAllColumnFilters () {
|
||||
|
||||
@@ -394,6 +394,12 @@ const routes = [
|
||||
component: () => import('pages/ProductPricing.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/product-performance-profitability',
|
||||
name: 'product-performance-profitability',
|
||||
component: () => import('pages/ProductPerformanceProfitability.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/brand-classification',
|
||||
name: 'brand-classification',
|
||||
|
||||
Reference in New Issue
Block a user