Merge remote-tracking branch 'origin/master'
This commit is contained in:
1
logs/backend-20260617-171953.err.log
Normal file
1
logs/backend-20260617-171953.err.log
Normal file
@@ -0,0 +1 @@
|
||||
exit status 1
|
||||
202
logs/backend-20260617-171953.out.log
Normal file
202
logs/backend-20260617-171953.out.log
Normal file
@@ -0,0 +1,202 @@
|
||||
time=2026-06-17T17:19:56.944+03:00 level=INFO msg="backend start" app=bssapp-backend scope=main
|
||||
time=2026-06-17T17:19:56.963+03:00 level=INFO msg="🔥🔥🔥 BSSAPP BACKEND STARTED — LOGIN ROUTE SHOULD EXIST 🔥🔥🔥" app=bssapp-backend
|
||||
time=2026-06-17T17:19:56.965+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-17T17:19:57.466+03:00 level=INFO msg="PostgreSQL bağlantısı başarılı" app=bssapp-backend
|
||||
time=2026-06-17T17:19:57.834+03:00 level=INFO msg="✅ Admin dept permissions seeded" app=bssapp-backend
|
||||
time=2026-06-17T17:19:57.835+03:00 level=INFO msg="🟢 auditlog Init called, buffer: 1000" app=bssapp-backend
|
||||
time=2026-06-17T17:19:57.836+03:00 level=INFO msg="🕵️ AuditLog sistemi başlatıldı (buffer=1000)" app=bssapp-backend
|
||||
time=2026-06-17T17:19:57.836+03:00 level=INFO msg="🟢 auditlog worker STARTED" app=bssapp-backend
|
||||
time=2026-06-17T17:19:57.922+03:00 level=INFO msg="[TranslationPerf] index_ready sql=\"CREATE EXTENSION IF NOT EXISTS pg_trgm\"" app=bssapp-backend
|
||||
time=2026-06-17T17:19:58.038+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-17T17:19:58.126+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-17T17:19:58.253+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-17T17:19:58.370+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-17T17:19:58.486+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-17T17:19:58.573+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-17T17:20:04.760+03:00 level=INFO msg="pricing calc infra bootstrap failed: pq: inconsistent types deduced for parameter $2" app=bssapp-backend
|
||||
time=2026-06-17T17:20:04.761+03:00 level=INFO msg="✉️ Graph Mailer hazır (App-only token) | from=baggiss@baggi.com.tr" app=bssapp-backend
|
||||
time=2026-06-17T17:20:04.761+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-17T17:20:05.880+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/auth/login [auth:login]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:06.868+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/auth/refresh [auth:refresh]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:07.797+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/password/forgot [auth:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:08.813+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/password/reset/validate/{token} [auth:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:09.794+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/password/reset [auth:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:10.835+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/password/change [auth:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:11.917+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/activity-logs [system:read]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:12.906+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/test-mail [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:13.902+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/market-mail-mappings/lookups [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:14.851+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/market-mail-mappings [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:15.778+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/system/market-mail-mappings/{marketId} [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:16.753+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/costing-mail-mappings/lookups [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:17.808+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/costing-mail-mappings [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:18.761+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/system/costing-mail-mappings/{group} [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:19.683+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/pricing-mail-mappings/lookups [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:20.618+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/system/pricing-mail-mappings [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:21.616+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/system/pricing-mail-mappings/{group} [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:22.567+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/language/translations [language:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:23.505+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/language/translations/{id} [language:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:24.485+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/language/translations/upsert-missing [language:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:25.483+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/language/translations/sync-sources [language:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:26.434+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/language/translations/translate-selected [language:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:27.367+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/language/translations/bulk-approve [language:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:28.287+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/language/translations/bulk-update [language:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:29.291+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/roles/{id}/permissions [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:30.260+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/roles/{id}/permissions [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:31.213+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/users/{id}/permissions [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:32.263+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users/{id}/permissions [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:33.213+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/permissions/routes [system:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:34.195+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/permissions/effective [system:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:35.211+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/permissions/matrix [system:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:36.179+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/role-dept-permissions/list [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:37.103+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/roles/{roleId}/departments/{deptCode}/permissions [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:38.103+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/roles/{roleId}/departments/{deptCode}/permissions [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:39.093+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/roles/{roleId}/departments/{deptCode}/members [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:40.073+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/roles/{roleId}/departments/{deptCode}/members [system:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:41.009+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/users/list [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:42.025+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users [user:insert]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:43.010+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/users/{id} [user:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:43.965+03:00 level=INFO msg="✅ Route+Perm registered → PUT /api/users/{id} [user:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:44.931+03:00 level=INFO msg="✅ Route+Perm registered → DELETE /api/users/{id} [user:delete]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:45.865+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users/{id}/admin-reset-password [user:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:46.850+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users/{id}/send-password-mail [user:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:47.785+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users/create [user:insert]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:48.767+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/departments-perm [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:49.683+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/modules [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:50.633+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/roles [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:51.565+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/departments [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:52.524+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/nebim-users [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:53.491+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/piyasalar [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:54.506+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/users-perm [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:55.553+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/lookups/roles-perm [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:56.517+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/accounts [customer:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:57.459+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/customer-list [customer:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:58.474+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/today-currency [finance:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:20:59.456+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/export-pdf [finance:export]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:00.438+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/export-pdf [finance:export]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:02.012+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/exportstamentheaderreport-pdf [finance:export]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:03.080+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/customer-balances [finance:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:04.011+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/customer-balances/export-pdf [finance:export]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:04.989+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/customer-balances/export-excel [finance:export]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:05.968+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/account-aging-statement [finance:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:06.941+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/account-aging-statement/export-pdf [finance:export]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:07.962+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-17T17:21:08.938+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/account-aging-statement/export-excel [finance:export]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:09.897+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/finance/aged-customer-balance-list [finance:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:10.858+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/statements [finance:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:11.839+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/statements/{id}/details [finance:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:12.834+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/create [order:insert]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:13.830+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/update [order:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:14.831+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/{id}/bulk-due-date [order:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:15.821+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/get/{id} [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:16.835+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/list [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:17.781+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/production-list [order:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:18.758+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/production-items/cditem-lookups [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:19.744+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/production-items/{id} [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:20.715+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/orders/production-items/{id}/insert-missing [order:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:21.703+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/orders/production-items/{id}/validate [order:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:22.703+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/orders/production-items/{id}/apply [order:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:23.637+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/close-ready [order:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:24.600+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/orders/bulk-close [order:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:25.583+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orders/export [order:export]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:26.590+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/check/{id} [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:27.614+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/validate [order:insert]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:28.540+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order/pdf/{id} [order:export]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:29.525+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/order/send-market-mail [order:read]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:30.483+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/order-inventory [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:31.498+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/orderpricelistb2b [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:32.418+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/min-price [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:33.351+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/products [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:34.281+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-detail [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:35.290+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-cditem [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:36.240+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-colors [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:37.239+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-newcolors [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:38.240+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-colorsize [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:39.199+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-secondcolor [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:40.260+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-newsecondcolor [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:41.611+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-attributes [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:42.993+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-item-attributes [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:44.028+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-stock-query [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:44.997+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-stock-attribute-options [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:45.923+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-stock-query-by-attributes [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:46.933+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-images [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:47.871+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-images/{id}/content [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:48.890+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/product-size-match/rules [order:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:49.901+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/products [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:50.832+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/products/options [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:51.814+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/products/export-all [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:52.790+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/products/price-list/export-excel [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:53.691+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/products/price-list/export-pdf [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:54.682+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/products/calculate-snapshots [pricing:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:55.704+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/products/{code}/price-history [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:56.654+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-17T17:21:57.642+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-17T17:21:58.670+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/products/save [pricing:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:21:59.616+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/brand-classification/lookups [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:00.524+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/brand-classification/brands [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:01.545+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/brand-classification/brands/sync [pricing:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:02.494+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/brand-classification/brand/{code}/group [pricing:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:03.392+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/brand-classification/brands/group-bulk [pricing:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:04.440+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/brand-group-currency [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:05.451+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/brand-group-currency/bulk-save [pricing:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:06.463+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/pricing-rules [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:07.457+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/pricing-rules/bulk-save [pricing:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:08.451+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/pricing-rules/import [pricing:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:09.426+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/pricing-rules/options [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:10.366+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/pricing-rules/parameters [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:11.334+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/pricing-rules/export-all [pricing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:12.501+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-17T17:22:13.497+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-17T17:22:14.458+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-17T17:22:15.420+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-17T17:22:16.437+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-17T17:22:17.387+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/production-types [costing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:18.338+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-17T17:22:19.351+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-17T17:22:20.351+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-17T17:22:21.313+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-17T17:22:22.285+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-17T17:22:23.294+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-17T17:22:24.308+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-17T17:22:25.201+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/onml/save [costing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:26.131+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/onml/pdf [costing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:27.029+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/pricing/production-product-costing/onml/delete [costing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:28.040+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/default-quantities [costing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:29.022+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-17T17:22:29.966+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-17T17:22:30.903+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-17T17:22:31.879+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-17T17:22:32.846+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-17T17:22:33.732+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-17T17:22:34.650+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/last10-warnings [costing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:35.539+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-17T17:22:36.456+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-17T17:22:37.400+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-17T17:22:38.337+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/pricing/production-product-costing/options/mtbolum [costing:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:39.290+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-17T17:22:40.393+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-17T17:22:41.306+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-17T17:22:42.229+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-17T17:22:43.177+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/roles [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:44.122+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/departments [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:45.040+03:00 level=INFO msg="✅ Route+Perm registered → GET /api/piyasalar [user:view]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:45.964+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/roles/{id}/departments [user:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:46.884+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/roles/{id}/piyasalar [user:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:47.837+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/users/{id}/roles [user:update]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:48.739+03:00 level=INFO msg="✅ Route+Perm registered → POST /api/admin/users/{id}/piyasa-sync [admin:user.update]" app=bssapp-backend
|
||||
time=2026-06-17T17:22:48.740+03:00 level=INFO msg="🌍 CORS Allowed Origin: http://ss.baggi.com.tr/app" app=bssapp-backend
|
||||
time=2026-06-17T17:22:48.740+03:00 level=INFO msg="🚀 Server running at: 0.0.0.0:8080" app=bssapp-backend
|
||||
time=2026-06-17T17:22:48.740+03:00 level=INFO msg="🕓 Translation sync next run at 2026-06-18T04:00:00+03:00 (in 10h37m11s)" app=bssapp-backend
|
||||
time=2026-06-17T17:22:48.742+03:00 level=INFO msg="listen tcp 0.0.0.0:8080: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted." app=bssapp-backend
|
||||
40
logs/ui-dev-20260617-171953.err.log
Normal file
40
logs/ui-dev-20260617-171953.err.log
Normal file
@@ -0,0 +1,40 @@
|
||||
npm warn Unknown env config "min-release-age". This will stop working in the next major version of npm.
|
||||
npm warn Unknown project config "shamefully-hoist". This will stop working in the next major version of npm.
|
||||
npm warn Unknown project config "strict-peer-dependencies". This will stop working in the next major version of npm.
|
||||
npm warn Unknown project config "resolution-mode". This will stop working in the next major version of npm.
|
||||
|
||||
App • ERROR • SPA UI
|
||||
|
||||
Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
|
||||
Did you forget to install it?
|
||||
|
||||
|
||||
App • ERROR • SPA UI
|
||||
|
||||
Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
|
||||
Did you forget to install it?
|
||||
|
||||
|
||||
App • ERROR • SPA UI
|
||||
|
||||
Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
|
||||
Did you forget to install it?
|
||||
|
||||
|
||||
App • ERROR • SPA UI
|
||||
|
||||
Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
|
||||
Did you forget to install it?
|
||||
|
||||
|
||||
App • ERROR • SPA UI
|
||||
|
||||
Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
|
||||
Did you forget to install it?
|
||||
|
||||
|
||||
App • ERROR • SPA UI
|
||||
|
||||
Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
|
||||
Did you forget to install it?
|
||||
|
||||
144
logs/ui-dev-20260617-171953.out.log
Normal file
144
logs/ui-dev-20260617-171953.out.log
Normal file
@@ -0,0 +1,144 @@
|
||||
|
||||
> baggisowtfaresystem@0.0.1 dev
|
||||
> quasar dev
|
||||
|
||||
|
||||
.d88888b.
|
||||
d88P" "Y88b
|
||||
888 888
|
||||
888 888 888 888 8888b. .d8888b 8888b. 888d888
|
||||
888 888 888 888 "88b 88K "88b 888P"
|
||||
888 Y8b 888 888 888 .d888888 "Y8888b. .d888888 888
|
||||
Y88b.Y8b88P Y88b 888 888 888 X88 888 888 888
|
||||
"Y888888" "Y88888 "Y888888 88888P' "Y888888 888
|
||||
Y8b
|
||||
|
||||
App • Using quasar.config.js in "esm" format
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 66615ms
|
||||
|
||||
» Reported at............... 17.06.2026 17:22:39
|
||||
» App dir................... D:\baggitekstilas\software projects\bssapp\bssapp\ui
|
||||
» App URL................... http://10.212.134.202:9000/
|
||||
http://100.127.32.153:9000/
|
||||
http://172.17.224.1:9000/
|
||||
http://192.168.1.106:9000/
|
||||
http://localhost:9000/
|
||||
» Dev mode.................. spa
|
||||
» Pkg quasar................ v2.18.6
|
||||
» Pkg @quasar/app-webpack... v4.3.2
|
||||
» Webpack transpiled JS..... yes (Babel)
|
||||
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled by Webpack with errors • 45ms
|
||||
|
||||
App • COMPILATION FAILED • Please check the log above for details.
|
||||
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 983ms
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled by Webpack with errors • 20ms
|
||||
|
||||
App • COMPILATION FAILED • Please check the log above for details.
|
||||
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 231ms
|
||||
App • Applying quasar.config file changes...
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled by Webpack with errors • 54ms
|
||||
|
||||
App • COMPILATION FAILED • Please check the log above for details.
|
||||
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 301ms
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled by Webpack with errors • 19ms
|
||||
|
||||
App • COMPILATION FAILED • Please check the log above for details.
|
||||
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 763ms
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 855ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 1791ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled by Webpack with errors • 26ms
|
||||
|
||||
App • COMPILATION FAILED • Please check the log above for details.
|
||||
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 722ms
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 473ms
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled by Webpack with errors • 34ms
|
||||
|
||||
App • COMPILATION FAILED • Please check the log above for details.
|
||||
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 208ms
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 760ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 750ms
|
||||
50
svc/main.go
50
svc/main.go
@@ -805,6 +805,41 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"pricing", "view",
|
||||
wrapV3(http.HandlerFunc(routes.ExportAllProductPricingHandler)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/price-list/export-excel", "POST",
|
||||
"pricing", "view",
|
||||
wrapV3(http.HandlerFunc(routes.ExportProductPriceListExcelHandler(pgDB))),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/price-list/export-pdf", "POST",
|
||||
"pricing", "view",
|
||||
wrapV3(http.HandlerFunc(routes.ExportProductPriceListPDFHandler(pgDB))),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/calculate-snapshots", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostProductPricingCalculateSnapshotsHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/{code}/price-history", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPricingHistoryHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/{code}/price-history/delete-latest", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostDeleteLatestProductPriceHistoryHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/{code}/price-history/delete-selected", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostDeleteSelectedProductPriceHistoryHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/save", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/brand-classification/lookups", "GET",
|
||||
"pricing", "view",
|
||||
@@ -830,6 +865,16 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"pricing", "update",
|
||||
wrapV3(routes.SetBrandGroupsBulkHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/brand-group-currency", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetBrandGroupCurrencyHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/brand-group-currency/bulk-save", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.SaveBrandGroupCurrencyHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/pricing-rules", "GET",
|
||||
"pricing", "view",
|
||||
@@ -1162,6 +1207,9 @@ func main() {
|
||||
if err := queries.EnsurePricingParameterTables(pgDB); err != nil {
|
||||
log.Println("mk_urunpricingprmtr bootstrap failed:", err)
|
||||
}
|
||||
if err := queries.EnsurePricingCalcInfraTables(pgDB); err != nil {
|
||||
log.Println("pricing calc infra bootstrap failed:", err)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// ✉️ MAILER INIT
|
||||
@@ -1184,6 +1232,8 @@ func main() {
|
||||
startTranslationSyncScheduler(pgDB, db.MssqlDB)
|
||||
startBrandSyncScheduler(pgDB, db.MssqlDB)
|
||||
startPricingParameterSyncScheduler(pgDB, db.MssqlDB)
|
||||
startProductPricingFxDeltaScheduler(pgDB)
|
||||
startProductPricingFxFullScheduler(pgDB)
|
||||
|
||||
handler := enableCORS(
|
||||
middlewares.GlobalAuthMiddleware(
|
||||
|
||||
123
svc/product_pricing_fx_delta_scheduler.go
Normal file
123
svc/product_pricing_fx_delta_scheduler.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/queries"
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
func startProductPricingFxDeltaScheduler(pgDB *sql.DB) {
|
||||
enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_PRICING_FX_DELTA_ENABLED")))
|
||||
if enabled == "0" || enabled == "false" || enabled == "off" {
|
||||
log.Println("Product pricing FX delta scheduler disabled")
|
||||
return
|
||||
}
|
||||
if pgDB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
intervalMin := 1
|
||||
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_DELTA_INTERVAL_MIN")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 1 {
|
||||
intervalMin = parsed
|
||||
}
|
||||
}
|
||||
batchSize := 200
|
||||
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_DELTA_BATCH_SIZE")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 10 && parsed <= 2000 {
|
||||
batchSize = parsed
|
||||
}
|
||||
}
|
||||
|
||||
var running int32 = 0
|
||||
|
||||
runOnce := func(reason string) {
|
||||
if db.PgDB == nil {
|
||||
return
|
||||
}
|
||||
if !atomic.CompareAndSwapInt32(&running, 0, 1) {
|
||||
log.Printf("[PricingFxDelta] skip (%s): already running", reason)
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&running, 0)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
totalClaimed := 0
|
||||
totalWritten := 0
|
||||
for {
|
||||
// Claim a batch.
|
||||
tx, err := pgDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
log.Printf("[PricingFxDelta] begin_tx_error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
items, err := queries.ClaimPriceRecalcQueue(ctx, tx, batchSize)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
log.Printf("[PricingFxDelta] claim_error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[PricingFxDelta] claim_commit_error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
totalClaimed += len(items)
|
||||
codes := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
if it.ProductCode != "" {
|
||||
codes = append(codes, it.ProductCode)
|
||||
}
|
||||
}
|
||||
|
||||
written, _, err := queries.PublishDerivedPricesFromAnchor(ctx, pgDB, codes, "", false)
|
||||
if err != nil {
|
||||
// Mark all failed.
|
||||
tx2, _ := pgDB.BeginTx(ctx, nil)
|
||||
if tx2 != nil {
|
||||
for _, it := range items {
|
||||
_ = queries.MarkPriceRecalcQueueFailed(ctx, tx2, it.ID, it.Attempts, err.Error())
|
||||
}
|
||||
_ = tx2.Commit()
|
||||
}
|
||||
log.Printf("[PricingFxDelta] publish_error (%s): claimed=%d err=%v", reason, len(items), err)
|
||||
return
|
||||
}
|
||||
totalWritten += written
|
||||
|
||||
// Mark all done (even if some were skipped due to missing anchor).
|
||||
tx3, _ := pgDB.BeginTx(ctx, nil)
|
||||
if tx3 != nil {
|
||||
for _, it := range items {
|
||||
_ = queries.MarkPriceRecalcQueueDone(ctx, tx3, it.ID)
|
||||
}
|
||||
_ = tx3.Commit()
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[PricingFxDelta] ok (%s): claimed=%d sdprc_written=%d interval_min=%d batch_size=%d", reason, totalClaimed, totalWritten, intervalMin, batchSize)
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
runOnce("startup")
|
||||
|
||||
ticker := time.NewTicker(time.Duration(intervalMin) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runOnce("scheduled")
|
||||
}
|
||||
}()
|
||||
}
|
||||
148
svc/product_pricing_fx_full_scheduler.go
Normal file
148
svc/product_pricing_fx_full_scheduler.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/queries"
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Weekly full FX publish job:
|
||||
// - Runs once every Monday at a configured local time.
|
||||
// - Recomputes derived currencies from anchor tiers and writes to sdprc for all products in mk_price_snapshot.
|
||||
func startProductPricingFxFullScheduler(pgDB *sql.DB) {
|
||||
enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_PRICING_FX_FULL_ENABLED")))
|
||||
// Be conservative: require explicit opt-in.
|
||||
if enabled != "1" && enabled != "true" && enabled != "on" && enabled != "yes" {
|
||||
log.Println("Product pricing FX full scheduler disabled (set PRODUCT_PRICING_FX_FULL_ENABLED=1 to enable)")
|
||||
return
|
||||
}
|
||||
if pgDB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Default: Monday 06:00 local time.
|
||||
runHH := 6
|
||||
runMM := 0
|
||||
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_FULL_HHMM")); raw != "" {
|
||||
parts := strings.Split(raw, ":")
|
||||
if len(parts) == 2 {
|
||||
if h, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && h >= 0 && h <= 23 {
|
||||
runHH = h
|
||||
}
|
||||
if m, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && m >= 0 && m <= 59 {
|
||||
runMM = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeBatch := 1000
|
||||
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_FULL_CODE_BATCH")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n >= 100 && n <= 5000 {
|
||||
codeBatch = n
|
||||
}
|
||||
}
|
||||
|
||||
var running int32 = 0
|
||||
|
||||
runOnce := func(reason string) {
|
||||
if db.PgDB == nil {
|
||||
return
|
||||
}
|
||||
if !atomic.CompareAndSwapInt32(&running, 0, 1) {
|
||||
log.Printf("[PricingFxFull] skip (%s): already running", reason)
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&running, 0)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour)
|
||||
defer cancel()
|
||||
|
||||
totalCodes := 0
|
||||
totalWritten := 0
|
||||
totalSkipped := 0
|
||||
|
||||
lastCode := ""
|
||||
for {
|
||||
rows, err := pgDB.QueryContext(ctx, `
|
||||
SELECT product_code
|
||||
FROM mk_price_snapshot
|
||||
WHERE COALESCE(NULLIF(BTRIM(product_code), ''), '') <> ''
|
||||
AND product_code > $1
|
||||
GROUP BY product_code
|
||||
ORDER BY product_code
|
||||
LIMIT $2
|
||||
`, lastCode, codeBatch)
|
||||
if err != nil {
|
||||
log.Printf("[PricingFxFull] list_codes_error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
|
||||
codes := make([]string, 0, codeBatch)
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
_ = rows.Close()
|
||||
log.Printf("[PricingFxFull] scan_code_error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
c = strings.TrimSpace(c)
|
||||
if c != "" {
|
||||
codes = append(codes, c)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
if len(codes) == 0 {
|
||||
break
|
||||
}
|
||||
lastCode = codes[len(codes)-1]
|
||||
|
||||
// Force FX refresh on the weekly run so Monday picks up the latest rates.
|
||||
written, skipped, err := queries.PublishDerivedPricesFromAnchor(ctx, pgDB, codes, "", true)
|
||||
if err != nil {
|
||||
log.Printf("[PricingFxFull] publish_error (%s): codes=%d err=%v", reason, len(codes), err)
|
||||
return
|
||||
}
|
||||
totalCodes += len(codes)
|
||||
totalWritten += written
|
||||
totalSkipped += skipped
|
||||
}
|
||||
|
||||
log.Printf("[PricingFxFull] ok (%s): products=%d sdprc_written=%d skipped=%d weekday=%d hhmm=%02d:%02d",
|
||||
reason, totalCodes, totalWritten, totalSkipped, int(time.Now().Weekday()), runHH, runMM)
|
||||
}
|
||||
|
||||
nextRun := func(now time.Time) time.Time {
|
||||
loc := now.Location()
|
||||
base := time.Date(now.Year(), now.Month(), now.Day(), runHH, runMM, 0, 0, loc)
|
||||
daysUntilMon := (int(time.Monday) - int(now.Weekday()) + 7) % 7
|
||||
candidate := base.AddDate(0, 0, daysUntilMon)
|
||||
// If today is Monday but the time has passed, schedule next Monday.
|
||||
if !candidate.After(now) {
|
||||
candidate = candidate.AddDate(0, 0, 7)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
for {
|
||||
now := time.Now()
|
||||
n := nextRun(now)
|
||||
d := time.Until(n)
|
||||
if d < 0 {
|
||||
d = time.Minute
|
||||
}
|
||||
log.Printf("[PricingFxFull] scheduled next_at=%s in=%s", n.Format(time.RFC3339), d.Round(time.Second))
|
||||
time.Sleep(d)
|
||||
runOnce("weekly")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type BrandGroupOption struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AnchorMode string `json:"anchor_mode"`
|
||||
}
|
||||
|
||||
func EnsureBrandClassificationTables(pg *sql.DB) error {
|
||||
@@ -41,10 +42,15 @@ CREATE TABLE IF NOT EXISTS mk_brandgrp (
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
anchor_mode TEXT NOT NULL DEFAULT 'USD',
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`ALTER TABLE mk_brandgrp ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE mk_brandgrp ADD COLUMN IF NOT EXISTS anchor_mode TEXT NOT NULL DEFAULT 'USD'`,
|
||||
`UPDATE mk_brandgrp SET anchor_mode='USD' WHERE COALESCE(NULLIF(BTRIM(anchor_mode), ''), '') = ''`,
|
||||
`ALTER TABLE mk_brandgrp DROP CONSTRAINT IF EXISTS ck_mk_brandgrp_anchor_mode`,
|
||||
`ALTER TABLE mk_brandgrp ADD CONSTRAINT ck_mk_brandgrp_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))`,
|
||||
`
|
||||
INSERT INTO mk_brandgrp (id, code, title, description, sort_order)
|
||||
VALUES
|
||||
@@ -74,7 +80,7 @@ CREATE TABLE IF NOT EXISTS mk_brandgrpmatch (
|
||||
}
|
||||
|
||||
func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error) {
|
||||
rows, err := pg.QueryContext(ctx, `SELECT id, code, title, description FROM mk_brandgrp ORDER BY sort_order, id`)
|
||||
rows, err := pg.QueryContext(ctx, `SELECT id, code, title, description, anchor_mode FROM mk_brandgrp ORDER BY sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -82,17 +88,57 @@ func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error
|
||||
out := make([]BrandGroupOption, 0, 8)
|
||||
for rows.Next() {
|
||||
var o BrandGroupOption
|
||||
if err := rows.Scan(&o.ID, &o.Code, &o.Title, &o.Description); err != nil {
|
||||
if err := rows.Scan(&o.ID, &o.Code, &o.Title, &o.Description, &o.AnchorMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.Code = strings.TrimSpace(o.Code)
|
||||
o.Title = strings.TrimSpace(o.Title)
|
||||
o.Description = strings.TrimSpace(o.Description)
|
||||
o.AnchorMode = strings.ToUpper(strings.TrimSpace(o.AnchorMode))
|
||||
if o.AnchorMode == "" {
|
||||
o.AnchorMode = "USD"
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func SetBrandGroupAnchorMode(ctx context.Context, tx *sql.Tx, grpID int, anchorMode string) error {
|
||||
anchorMode = strings.ToUpper(strings.TrimSpace(anchorMode))
|
||||
if anchorMode == "" {
|
||||
anchorMode = "USD"
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_brandgrp
|
||||
SET anchor_mode=$2
|
||||
WHERE id=$1
|
||||
`, grpID, anchorMode)
|
||||
return err
|
||||
}
|
||||
|
||||
func SyncPricingRuleAnchorModesByGroup(ctx context.Context, tx *sql.Tx, grpID int, anchorMode string) error {
|
||||
anchorMode = strings.ToUpper(strings.TrimSpace(anchorMode))
|
||||
if anchorMode == "" {
|
||||
anchorMode = "USD"
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_pricing_rule r
|
||||
SET anchor_mode=$2,
|
||||
updated_at=now()
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM mk_brandgrp g
|
||||
JOIN LATERAL unnest(r.brand_group) bg(value) ON TRUE
|
||||
WHERE g.id=$1
|
||||
AND (
|
||||
UPPER(BTRIM(bg.value)) = UPPER(BTRIM(g.code))
|
||||
OR UPPER(BTRIM(bg.value)) = UPPER(BTRIM(g.title))
|
||||
)
|
||||
)
|
||||
`, grpID, anchorMode)
|
||||
return err
|
||||
}
|
||||
|
||||
func ListBrandsWithGroups(ctx context.Context, pg *sql.DB, q string, limit int) ([]BrandRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5000
|
||||
|
||||
679
svc/queries/pricing_calc_engine.go
Normal file
679
svc/queries/pricing_calc_engine.go
Normal file
@@ -0,0 +1,679 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/models"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PricingFxRateCacheRow struct {
|
||||
RateDate string `json:"rate_date"`
|
||||
UsdTry float64 `json:"usd_try"`
|
||||
EurTry float64 `json:"eur_try"`
|
||||
UsdEur float64 `json:"usd_eur"`
|
||||
}
|
||||
|
||||
type ProductPricingSnapshotCalcRequest struct {
|
||||
ProductCodes []string
|
||||
Filters ProductPricingFilters
|
||||
RateDate string
|
||||
ForceFxRefresh bool
|
||||
}
|
||||
|
||||
type ProductPricingSnapshotCalcResult struct {
|
||||
RateDate string `json:"rate_date"`
|
||||
UsdTry float64 `json:"usd_try"`
|
||||
EurTry float64 `json:"eur_try"`
|
||||
UsdEur float64 `json:"usd_eur"`
|
||||
Requested int `json:"requested"`
|
||||
Calculated int `json:"calculated"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
type ProductPricingSnapshotPreviewRow struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
AnchorMode string `json:"anchor_mode"`
|
||||
BasePriceUsd float64 `json:"base_price_usd"`
|
||||
BasePriceTry float64 `json:"base_price_try"`
|
||||
USD1 float64 `json:"usd1"`
|
||||
USD2 float64 `json:"usd2"`
|
||||
USD3 float64 `json:"usd3"`
|
||||
USD4 float64 `json:"usd4"`
|
||||
USD5 float64 `json:"usd5"`
|
||||
USD6 float64 `json:"usd6"`
|
||||
EUR1 float64 `json:"eur1"`
|
||||
EUR2 float64 `json:"eur2"`
|
||||
EUR3 float64 `json:"eur3"`
|
||||
EUR4 float64 `json:"eur4"`
|
||||
EUR5 float64 `json:"eur5"`
|
||||
EUR6 float64 `json:"eur6"`
|
||||
TRY1 float64 `json:"try1"`
|
||||
TRY2 float64 `json:"try2"`
|
||||
TRY3 float64 `json:"try3"`
|
||||
TRY4 float64 `json:"try4"`
|
||||
TRY5 float64 `json:"try5"`
|
||||
TRY6 float64 `json:"try6"`
|
||||
}
|
||||
|
||||
type ProductPricingSnapshotPreviewResult struct {
|
||||
RateDate string `json:"rate_date"`
|
||||
UsdTry float64 `json:"usd_try"`
|
||||
EurTry float64 `json:"eur_try"`
|
||||
UsdEur float64 `json:"usd_eur"`
|
||||
Requested int `json:"requested"`
|
||||
Calculated int `json:"calculated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Rows []ProductPricingSnapshotPreviewRow `json:"rows"`
|
||||
}
|
||||
|
||||
func resolvePricingFxRateByDate(ctx context.Context, pg *sql.DB, rateDate string, forceRefresh bool, persist bool) (PricingFxRateCacheRow, error) {
|
||||
var out PricingFxRateCacheRow
|
||||
rateDate = normalizeCalcDate(rateDate)
|
||||
if rateDate == "" {
|
||||
rateDate = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
if !forceRefresh {
|
||||
err := pg.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
TO_CHAR(rate_date, 'YYYY-MM-DD'),
|
||||
usd_try::float8,
|
||||
eur_try::float8,
|
||||
usd_eur::float8
|
||||
FROM mk_fx_rate_cache
|
||||
WHERE rate_date=$1::date
|
||||
`, rateDate).Scan(&out.RateDate, &out.UsdTry, &out.EurTry, &out.UsdEur)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
if db.MssqlDB == nil {
|
||||
return out, fmt.Errorf("mssql pricing db not available")
|
||||
}
|
||||
row, err := GetProductionHasCostDetailExchangeRatesByDate(ctx, db.MssqlDB, rateDate)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var (
|
||||
rateDateResolved string
|
||||
usdTry float64
|
||||
eurTry float64
|
||||
gbpIgnored float64
|
||||
)
|
||||
if err := row.Scan(&rateDateResolved, &usdTry, &eurTry, &gbpIgnored); err != nil {
|
||||
return out, err
|
||||
}
|
||||
rateDateResolved = normalizeCalcDate(rateDateResolved)
|
||||
if rateDateResolved == "" {
|
||||
rateDateResolved = rateDate
|
||||
}
|
||||
usdEur := 0.0
|
||||
if usdTry > 0 && eurTry > 0 {
|
||||
usdEur = roundCalcValue(usdTry / eurTry)
|
||||
}
|
||||
|
||||
if persist {
|
||||
if _, err := pg.ExecContext(ctx, `
|
||||
INSERT INTO mk_fx_rate_cache (
|
||||
rate_date, usd_try, eur_try, usd_eur, source_system, source_updated_at, created_at, updated_at
|
||||
)
|
||||
VALUES ($1::date, $2, $3, $4, 'MSSQL', now(), now(), now())
|
||||
ON CONFLICT (rate_date)
|
||||
DO UPDATE SET
|
||||
usd_try=EXCLUDED.usd_try,
|
||||
eur_try=EXCLUDED.eur_try,
|
||||
usd_eur=EXCLUDED.usd_eur,
|
||||
source_system=EXCLUDED.source_system,
|
||||
source_updated_at=EXCLUDED.source_updated_at,
|
||||
updated_at=now()
|
||||
`, rateDateResolved, usdTry, eurTry, usdEur); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
out = PricingFxRateCacheRow{
|
||||
RateDate: rateDateResolved,
|
||||
UsdTry: usdTry,
|
||||
EurTry: eurTry,
|
||||
UsdEur: usdEur,
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func SyncPricingFxRateCacheByDate(ctx context.Context, pg *sql.DB, rateDate string, forceRefresh bool) (PricingFxRateCacheRow, error) {
|
||||
return resolvePricingFxRateByDate(ctx, pg, rateDate, forceRefresh, true)
|
||||
}
|
||||
|
||||
func CalculateProductPricingSnapshots(ctx context.Context, pg *sql.DB, req ProductPricingSnapshotCalcRequest) (ProductPricingSnapshotCalcResult, error) {
|
||||
var result ProductPricingSnapshotCalcResult
|
||||
rateRow, err := resolvePricingFxRateByDate(ctx, pg, req.RateDate, req.ForceFxRefresh, true)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.RateDate = rateRow.RateDate
|
||||
result.UsdTry = rateRow.UsdTry
|
||||
result.EurTry = rateRow.EurTry
|
||||
result.UsdEur = rateRow.UsdEur
|
||||
if rateRow.UsdTry <= 0 || rateRow.EurTry <= 0 || rateRow.UsdEur <= 0 {
|
||||
return result, fmt.Errorf("invalid fx rates for date %s", rateRow.RateDate)
|
||||
}
|
||||
|
||||
filters := req.Filters
|
||||
if len(req.ProductCodes) > 0 {
|
||||
filters.ProductCode = dedupeTrimmedStrings(req.ProductCodes)
|
||||
}
|
||||
|
||||
rows, err := GetAllProductPricingRows(ctx, 1000, filters, "productCode", false)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Requested = len(rows)
|
||||
if len(rows) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
ruleRows, err := ListPricingParameterRules(ctx, pg, PricingRuleOptionFilters{})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
rulesByScope := make(map[string]PricingParameterRuleRow, len(ruleRows))
|
||||
for _, item := range ruleRows {
|
||||
rulesByScope[item.ScopeKey] = item
|
||||
}
|
||||
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, product := range rows {
|
||||
scopeKey := pricingParameterScopeKey(pricingParameterRow{
|
||||
AskiliYan: strings.TrimSpace(product.AskiliYan),
|
||||
Kategori: strings.TrimSpace(product.Kategori),
|
||||
UrunIlkGrubu: strings.TrimSpace(product.UrunIlkGrubu),
|
||||
UrunAnaGrubu: strings.TrimSpace(product.UrunAnaGrubu),
|
||||
UrunAltGrubu: strings.TrimSpace(product.UrunAltGrubu),
|
||||
Icerik: strings.TrimSpace(product.Icerik),
|
||||
Marka: strings.TrimSpace(product.Marka),
|
||||
BrandCode: strings.TrimSpace(product.BrandCode),
|
||||
BrandGroupSec: strings.TrimSpace(product.BrandGroupSec),
|
||||
})
|
||||
ruleItem, ok := rulesByScope[scopeKey]
|
||||
if !ok || ruleItem.Rule == nil {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if !ruleItem.Rule.CalcEnabled || !ruleItem.Rule.IsActive {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
snapshot, ok := buildPricingSnapshotRow(product, ruleItem, rateRow)
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if err := upsertPricingSnapshot(ctx, tx, snapshot); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Calculated++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func PreviewProductPricingSnapshots(ctx context.Context, pg *sql.DB, req ProductPricingSnapshotCalcRequest) (ProductPricingSnapshotPreviewResult, error) {
|
||||
var result ProductPricingSnapshotPreviewResult
|
||||
rateRow, err := resolvePricingFxRateByDate(ctx, pg, req.RateDate, req.ForceFxRefresh, false)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.RateDate = rateRow.RateDate
|
||||
result.UsdTry = rateRow.UsdTry
|
||||
result.EurTry = rateRow.EurTry
|
||||
result.UsdEur = rateRow.UsdEur
|
||||
if rateRow.UsdTry <= 0 || rateRow.EurTry <= 0 || rateRow.UsdEur <= 0 {
|
||||
return result, fmt.Errorf("invalid fx rates for date %s", rateRow.RateDate)
|
||||
}
|
||||
|
||||
filters := req.Filters
|
||||
if len(req.ProductCodes) > 0 {
|
||||
filters.ProductCode = dedupeTrimmedStrings(req.ProductCodes)
|
||||
}
|
||||
|
||||
rows, err := GetAllProductPricingRows(ctx, 1000, filters, "productCode", false)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Requested = len(rows)
|
||||
if len(rows) == 0 {
|
||||
result.Rows = []ProductPricingSnapshotPreviewRow{}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
ruleRows, err := ListPricingParameterRules(ctx, pg, PricingRuleOptionFilters{})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
rulesByScope := make(map[string]PricingParameterRuleRow, len(ruleRows))
|
||||
for _, item := range ruleRows {
|
||||
rulesByScope[item.ScopeKey] = item
|
||||
}
|
||||
|
||||
outRows := make([]ProductPricingSnapshotPreviewRow, 0, len(rows))
|
||||
for _, product := range rows {
|
||||
scopeKey := pricingParameterScopeKey(pricingParameterRow{
|
||||
AskiliYan: strings.TrimSpace(product.AskiliYan),
|
||||
Kategori: strings.TrimSpace(product.Kategori),
|
||||
UrunIlkGrubu: strings.TrimSpace(product.UrunIlkGrubu),
|
||||
UrunAnaGrubu: strings.TrimSpace(product.UrunAnaGrubu),
|
||||
UrunAltGrubu: strings.TrimSpace(product.UrunAltGrubu),
|
||||
Icerik: strings.TrimSpace(product.Icerik),
|
||||
Marka: strings.TrimSpace(product.Marka),
|
||||
BrandCode: strings.TrimSpace(product.BrandCode),
|
||||
BrandGroupSec: strings.TrimSpace(product.BrandGroupSec),
|
||||
})
|
||||
ruleItem, ok := rulesByScope[scopeKey]
|
||||
if !ok || ruleItem.Rule == nil {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if !ruleItem.Rule.CalcEnabled || !ruleItem.Rule.IsActive {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
snapshot, ok := buildPricingSnapshotRow(product, ruleItem, rateRow)
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
outRows = append(outRows, previewRowFromSnapshot(snapshot))
|
||||
result.Calculated++
|
||||
}
|
||||
result.Rows = outRows
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type pricingSnapshotRow struct {
|
||||
ProductCode string
|
||||
PricingParameterID int64
|
||||
RuleID string
|
||||
StrategyCode string
|
||||
AnchorMode string
|
||||
FxDate string
|
||||
CostDate string
|
||||
BasePriceTry float64
|
||||
BasePriceUsd float64
|
||||
Try [6]float64
|
||||
Usd [6]float64
|
||||
Eur [6]float64
|
||||
CalcHash string
|
||||
}
|
||||
|
||||
func buildPricingSnapshotRow(product models.ProductPricing, ruleItem PricingParameterRuleRow, fx PricingFxRateCacheRow) (pricingSnapshotRow, bool) {
|
||||
var out pricingSnapshotRow
|
||||
rule := ruleItem.Rule
|
||||
if rule == nil {
|
||||
return out, false
|
||||
}
|
||||
|
||||
anchorMode := strings.ToUpper(strings.TrimSpace(rule.AnchorMode))
|
||||
if anchorMode != "TRY" && anchorMode != "USD" {
|
||||
anchorMode = "USD"
|
||||
}
|
||||
strategyCode := strings.ToUpper(strings.TrimSpace(rule.StrategyCode))
|
||||
if strategyCode != "CORE" && strategyCode != "PREMIUM" && strategyCode != "SARTORIAL" {
|
||||
strategyCode = strings.ToUpper(strings.TrimSpace(product.BrandGroupSec))
|
||||
}
|
||||
if strategyCode != "CORE" && strategyCode != "PREMIUM" && strategyCode != "SARTORIAL" {
|
||||
strategyCode = "CORE"
|
||||
}
|
||||
|
||||
costUSD := roundCalcValue(product.CostPrice)
|
||||
if costUSD <= 0 {
|
||||
return out, false
|
||||
}
|
||||
|
||||
baseUSD := 0.0
|
||||
baseTRY := 0.0
|
||||
switch anchorMode {
|
||||
case "TRY":
|
||||
if rule.TryBase > 0 {
|
||||
baseTRY = roundCalcValue(costUSD * fx.UsdTry * rule.TryBase)
|
||||
} else if product.BasePriceTry > 0 {
|
||||
baseTRY = roundCalcValue(product.BasePriceTry)
|
||||
} else if product.BasePriceUsd > 0 {
|
||||
baseTRY = roundCalcValue(product.BasePriceUsd * fx.UsdTry)
|
||||
} else if rule.UsdBase > 0 {
|
||||
baseTRY = roundCalcValue(costUSD * rule.UsdBase * fx.UsdTry)
|
||||
}
|
||||
if baseTRY <= 0 {
|
||||
return out, false
|
||||
}
|
||||
baseUSD = roundCalcValue(baseTRY / fx.UsdTry)
|
||||
default:
|
||||
if rule.UsdBase > 0 {
|
||||
baseUSD = roundCalcValue(costUSD * rule.UsdBase)
|
||||
} else if product.BasePriceUsd > 0 {
|
||||
baseUSD = roundCalcValue(product.BasePriceUsd)
|
||||
} else if product.BasePriceTry > 0 {
|
||||
baseUSD = roundCalcValue(product.BasePriceTry / fx.UsdTry)
|
||||
}
|
||||
if baseUSD <= 0 {
|
||||
return out, false
|
||||
}
|
||||
baseTRY = roundCalcValue(baseUSD * fx.UsdTry)
|
||||
}
|
||||
baseEUR := roundCalcValue(baseUSD * fx.UsdEur)
|
||||
|
||||
tryBaseForCalc := baseTRY
|
||||
usdBaseForCalc := baseUSD
|
||||
eurBaseForCalc := baseEUR
|
||||
if tryBaseForCalc <= 0 || usdBaseForCalc <= 0 || eurBaseForCalc <= 0 {
|
||||
return out, false
|
||||
}
|
||||
|
||||
tryMultipliers := [6]float64{rule.Try1, rule.Try2, rule.Try3, rule.Try4, rule.Try5, rule.Try6}
|
||||
usdMultipliers := [6]float64{rule.Usd1, rule.Usd2, rule.Usd3, rule.Usd4, rule.Usd5, rule.Usd6}
|
||||
eurMultipliers := [6]float64{rule.Eur1, rule.Eur2, rule.Eur3, rule.Eur4, rule.Eur5, rule.Eur6}
|
||||
|
||||
prevTry := tryBaseForCalc
|
||||
prevUsd := usdBaseForCalc
|
||||
prevEur := eurBaseForCalc
|
||||
for i := 0; i < 6; i++ {
|
||||
tryRaw := prevTry * tryMultipliers[i]
|
||||
usdRaw := prevUsd * usdMultipliers[i]
|
||||
eurRaw := prevEur * eurMultipliers[i]
|
||||
|
||||
tryStep := rule.TryWholesaleStep
|
||||
usdStep := rule.UsdWholesaleStep
|
||||
eurStep := rule.EurWholesaleStep
|
||||
if i == 5 {
|
||||
out.Try[i] = applyRetailRounding(tryRaw, rule.TryWholesaleStep, rule.TryRetailStep, rule.TryRetailMode)
|
||||
out.Usd[i] = applyRetailRounding(usdRaw, rule.UsdWholesaleStep, rule.UsdRetailStep, rule.UsdRetailMode)
|
||||
out.Eur[i] = applyRetailRounding(eurRaw, rule.EurWholesaleStep, rule.EurRetailStep, rule.EurRetailMode)
|
||||
prevTry = out.Try[i]
|
||||
prevUsd = out.Usd[i]
|
||||
prevEur = out.Eur[i]
|
||||
continue
|
||||
}
|
||||
|
||||
out.Try[i] = roundUpStep(tryRaw, tryStep)
|
||||
out.Usd[i] = roundUpStep(usdRaw, usdStep)
|
||||
out.Eur[i] = roundUpStep(eurRaw, eurStep)
|
||||
prevTry = out.Try[i]
|
||||
prevUsd = out.Usd[i]
|
||||
prevEur = out.Eur[i]
|
||||
}
|
||||
|
||||
out.ProductCode = strings.TrimSpace(product.ProductCode)
|
||||
out.PricingParameterID = ruleItem.PricingParameterID
|
||||
out.RuleID = strings.TrimSpace(rule.ID)
|
||||
out.StrategyCode = strategyCode
|
||||
out.AnchorMode = anchorMode
|
||||
out.FxDate = fx.RateDate
|
||||
out.CostDate = normalizeCalcDate(product.LastCostingDate)
|
||||
out.BasePriceTry = baseTRY
|
||||
out.BasePriceUsd = baseUSD
|
||||
out.CalcHash = pricingSnapshotHash(out, fx)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func applyRetailRounding(raw, wholesaleStep, retailStep float64, retailMode string) float64 {
|
||||
baseRounded := roundUpStep(raw, wholesaleStep)
|
||||
mode := normalizeRetailMode(retailMode)
|
||||
switch mode {
|
||||
case "END_99":
|
||||
return roundUpToEnding(baseRounded, 99)
|
||||
case "END_49":
|
||||
return roundUpToEnding(baseRounded, 49)
|
||||
case "BAND_99":
|
||||
return roundUpToBandEnding(baseRounded, retailStep, 99)
|
||||
case "BAND_49":
|
||||
return roundUpToBandEnding(baseRounded, retailStep, 49)
|
||||
default:
|
||||
if retailStep > 0 {
|
||||
return roundUpStep(baseRounded, retailStep)
|
||||
}
|
||||
return baseRounded
|
||||
}
|
||||
}
|
||||
|
||||
func roundUpToEnding(value float64, ending int) float64 {
|
||||
value = roundCalcValue(value)
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
switch ending {
|
||||
case 99:
|
||||
return roundCalcValue(psychologicalEnding99(value))
|
||||
case 49:
|
||||
return roundCalcValue(psychologicalEnding49(value))
|
||||
default:
|
||||
whole := math.Floor(value + 1e-9)
|
||||
candidate := whole + (float64(ending) / 100.0)
|
||||
if candidate+1e-9 < value {
|
||||
candidate = whole + 1 + (float64(ending) / 100.0)
|
||||
}
|
||||
return roundCalcValue(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func roundUpToBandEnding(value, band float64, ending int) float64 {
|
||||
value = roundCalcValue(value)
|
||||
band = roundCalcValue(band)
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if band <= 0 {
|
||||
return roundUpToEnding(value, ending)
|
||||
}
|
||||
units := math.Ceil((value - 1e-9) / band)
|
||||
candidate := (units * band) - 1 + (float64(ending) / 100.0)
|
||||
if candidate+1e-9 < value {
|
||||
candidate = ((units + 1) * band) - 1 + (float64(ending) / 100.0)
|
||||
}
|
||||
return roundCalcValue(candidate)
|
||||
}
|
||||
|
||||
func psychologicalEnding99(value float64) float64 {
|
||||
whole := math.Floor(value + 1e-9)
|
||||
fraction := value - whole
|
||||
if fraction >= 0.90 {
|
||||
return whole + 0.99
|
||||
}
|
||||
return whole - 0.01
|
||||
}
|
||||
|
||||
func psychologicalEnding49(value float64) float64 {
|
||||
whole := math.Floor(value + 1e-9)
|
||||
fraction := value - whole
|
||||
if fraction >= 0.40 {
|
||||
return whole + 0.49
|
||||
}
|
||||
return whole - 0.51
|
||||
}
|
||||
|
||||
func upsertPricingSnapshot(ctx context.Context, tx *sql.Tx, row pricingSnapshotRow) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_price_snapshot (
|
||||
product_code, pricing_parameter_id, rule_id, strategy_code, anchor_mode, fx_date, cost_date,
|
||||
base_price_try, base_price_usd,
|
||||
try1, try2, try3, try4, try5, try6,
|
||||
usd1, usd2, usd3, usd4, usd5, usd6,
|
||||
eur1, eur2, eur3, eur4, eur5, eur6,
|
||||
calc_hash, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1,$2,NULLIF($3,'')::uuid,$4,$5,$6::date,NULLIF($7,'')::date,
|
||||
$8,$9,
|
||||
$10,$11,$12,$13,$14,$15,
|
||||
$16,$17,$18,$19,$20,$21,
|
||||
$22,$23,$24,$25,$26,$27,
|
||||
$28,now(),now()
|
||||
)
|
||||
ON CONFLICT (product_code, pricing_parameter_id)
|
||||
DO UPDATE SET
|
||||
rule_id=NULLIF(EXCLUDED.rule_id::text,'')::uuid,
|
||||
strategy_code=EXCLUDED.strategy_code,
|
||||
anchor_mode=EXCLUDED.anchor_mode,
|
||||
fx_date=EXCLUDED.fx_date,
|
||||
cost_date=EXCLUDED.cost_date,
|
||||
base_price_try=EXCLUDED.base_price_try,
|
||||
base_price_usd=EXCLUDED.base_price_usd,
|
||||
try1=EXCLUDED.try1,
|
||||
try2=EXCLUDED.try2,
|
||||
try3=EXCLUDED.try3,
|
||||
try4=EXCLUDED.try4,
|
||||
try5=EXCLUDED.try5,
|
||||
try6=EXCLUDED.try6,
|
||||
usd1=EXCLUDED.usd1,
|
||||
usd2=EXCLUDED.usd2,
|
||||
usd3=EXCLUDED.usd3,
|
||||
usd4=EXCLUDED.usd4,
|
||||
usd5=EXCLUDED.usd5,
|
||||
usd6=EXCLUDED.usd6,
|
||||
eur1=EXCLUDED.eur1,
|
||||
eur2=EXCLUDED.eur2,
|
||||
eur3=EXCLUDED.eur3,
|
||||
eur4=EXCLUDED.eur4,
|
||||
eur5=EXCLUDED.eur5,
|
||||
eur6=EXCLUDED.eur6,
|
||||
calc_hash=EXCLUDED.calc_hash,
|
||||
updated_at=now()
|
||||
`, row.ProductCode, row.PricingParameterID, row.RuleID, row.StrategyCode, row.AnchorMode, row.FxDate, row.CostDate,
|
||||
row.BasePriceTry, row.BasePriceUsd,
|
||||
row.Try[0], row.Try[1], row.Try[2], row.Try[3], row.Try[4], row.Try[5],
|
||||
row.Usd[0], row.Usd[1], row.Usd[2], row.Usd[3], row.Usd[4], row.Usd[5],
|
||||
row.Eur[0], row.Eur[1], row.Eur[2], row.Eur[3], row.Eur[4], row.Eur[5],
|
||||
row.CalcHash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func previewRowFromSnapshot(row pricingSnapshotRow) ProductPricingSnapshotPreviewRow {
|
||||
return ProductPricingSnapshotPreviewRow{
|
||||
ProductCode: row.ProductCode,
|
||||
AnchorMode: row.AnchorMode,
|
||||
BasePriceUsd: roundCalcValue(row.BasePriceUsd),
|
||||
BasePriceTry: roundCalcValue(row.BasePriceTry),
|
||||
USD1: roundCalcValue(row.Usd[0]),
|
||||
USD2: roundCalcValue(row.Usd[1]),
|
||||
USD3: roundCalcValue(row.Usd[2]),
|
||||
USD4: roundCalcValue(row.Usd[3]),
|
||||
USD5: roundCalcValue(row.Usd[4]),
|
||||
USD6: roundCalcValue(row.Usd[5]),
|
||||
EUR1: roundCalcValue(row.Eur[0]),
|
||||
EUR2: roundCalcValue(row.Eur[1]),
|
||||
EUR3: roundCalcValue(row.Eur[2]),
|
||||
EUR4: roundCalcValue(row.Eur[3]),
|
||||
EUR5: roundCalcValue(row.Eur[4]),
|
||||
EUR6: roundCalcValue(row.Eur[5]),
|
||||
TRY1: roundCalcValue(row.Try[0]),
|
||||
TRY2: roundCalcValue(row.Try[1]),
|
||||
TRY3: roundCalcValue(row.Try[2]),
|
||||
TRY4: roundCalcValue(row.Try[3]),
|
||||
TRY5: roundCalcValue(row.Try[4]),
|
||||
TRY6: roundCalcValue(row.Try[5]),
|
||||
}
|
||||
}
|
||||
|
||||
func roundUpStep(value, step float64) float64 {
|
||||
value = roundCalcValue(value)
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
step = roundCalcValue(step)
|
||||
if step <= 0 {
|
||||
return value
|
||||
}
|
||||
units := math.Ceil((value - 1e-9) / step)
|
||||
return roundCalcValue(units * step)
|
||||
}
|
||||
|
||||
func roundCalcValue(value float64) float64 {
|
||||
if !isFiniteCalc(value) {
|
||||
return 0
|
||||
}
|
||||
return math.Round(value*1_000_000) / 1_000_000
|
||||
}
|
||||
|
||||
func isFiniteCalc(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
func normalizeCalcDate(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if len(value) >= 10 {
|
||||
value = value[:10]
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", value); err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func dedupeTrimmedStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
val := strings.TrimSpace(raw)
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[val]; ok {
|
||||
continue
|
||||
}
|
||||
seen[val] = struct{}{}
|
||||
out = append(out, val)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func pricingSnapshotHash(row pricingSnapshotRow, fx PricingFxRateCacheRow) string {
|
||||
parts := []string{
|
||||
row.ProductCode,
|
||||
fmt.Sprintf("%d", row.PricingParameterID),
|
||||
row.RuleID,
|
||||
row.StrategyCode,
|
||||
row.AnchorMode,
|
||||
row.FxDate,
|
||||
row.CostDate,
|
||||
fmt.Sprintf("%.6f", row.BasePriceTry),
|
||||
fmt.Sprintf("%.6f", row.BasePriceUsd),
|
||||
fmt.Sprintf("%.6f", fx.UsdTry),
|
||||
fmt.Sprintf("%.6f", fx.EurTry),
|
||||
fmt.Sprintf("%.6f", fx.UsdEur),
|
||||
}
|
||||
for _, value := range row.Try {
|
||||
parts = append(parts, fmt.Sprintf("%.6f", value))
|
||||
}
|
||||
for _, value := range row.Usd {
|
||||
parts = append(parts, fmt.Sprintf("%.6f", value))
|
||||
}
|
||||
for _, value := range row.Eur {
|
||||
parts = append(parts, fmt.Sprintf("%.6f", value))
|
||||
}
|
||||
sum := md5.Sum([]byte(strings.Join(parts, string(rune(31)))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
188
svc/queries/pricing_calc_infra.go
Normal file
188
svc/queries/pricing_calc_infra.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func EnsurePricingCalcInfraTables(pg *sql.DB) error {
|
||||
stmts := []string{
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_fx_rate_cache (
|
||||
rate_date DATE PRIMARY KEY,
|
||||
usd_try NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur_try NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd_eur NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
source_system TEXT NOT NULL DEFAULT 'MSSQL',
|
||||
source_updated_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_fx_rate_cache_updated_at ON mk_fx_rate_cache (updated_at DESC)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_price_snapshot (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
product_code TEXT NOT NULL,
|
||||
pricing_parameter_id BIGINT REFERENCES mk_urunpricingprmtr(id) ON DELETE CASCADE,
|
||||
rule_id UUID REFERENCES mk_pricing_rule(id) ON DELETE SET NULL,
|
||||
strategy_code TEXT NOT NULL DEFAULT 'CORE',
|
||||
anchor_mode TEXT NOT NULL DEFAULT 'USD',
|
||||
fx_date DATE NOT NULL,
|
||||
cost_date DATE,
|
||||
base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try1 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try2 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try3 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try4 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try5 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try6 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd1 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd2 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd3 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd4 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd5 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd6 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur1 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur2 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur3 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur4 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur5 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur6 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
calc_hash TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_mk_price_snapshot_product_scope UNIQUE (product_code, pricing_parameter_id),
|
||||
CONSTRAINT ck_mk_price_snapshot_strategy_code CHECK (strategy_code IN ('CORE','PREMIUM','SARTORIAL')),
|
||||
CONSTRAINT ck_mk_price_snapshot_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_price_snapshot_rule ON mk_price_snapshot (rule_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_price_snapshot_updated_at ON mk_price_snapshot (updated_at DESC)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_price_target_map_pg (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
currency TEXT NOT NULL,
|
||||
level_no SMALLINT NOT NULL,
|
||||
sdprcgrp_id INTEGER,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_mk_price_target_map_pg UNIQUE (currency, level_no),
|
||||
CONSTRAINT ck_mk_price_target_map_pg_currency CHECK (currency IN ('TRY','USD','EUR')),
|
||||
CONSTRAINT ck_mk_price_target_map_pg_level_no CHECK (level_no BETWEEN 1 AND 6)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_price_target_map_pg_active ON mk_price_target_map_pg (is_active, currency, level_no)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_price_target_map_nebim (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
currency TEXT NOT NULL,
|
||||
level_no SMALLINT NOT NULL,
|
||||
price_group_code TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_mk_price_target_map_nebim UNIQUE (currency, level_no),
|
||||
CONSTRAINT ck_mk_price_target_map_nebim_currency CHECK (currency IN ('TRY','USD','EUR')),
|
||||
CONSTRAINT ck_mk_price_target_map_nebim_level_no CHECK (level_no BETWEEN 1 AND 6)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_price_target_map_nebim_active ON mk_price_target_map_nebim (is_active, currency, level_no)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_price_recalc_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
product_code TEXT NOT NULL,
|
||||
pricing_parameter_id BIGINT REFERENCES mk_urunpricingprmtr(id) ON DELETE SET NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMPTZ,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ck_mk_price_recalc_queue_status CHECK (status IN ('pending','processing','done','failed'))
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_price_recalc_queue_status ON mk_price_recalc_queue (status, available_at, queued_at)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_mk_price_recalc_queue_pending ON mk_price_recalc_queue (product_code, COALESCE(pricing_parameter_id, 0)) WHERE status IN ('pending','processing')`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_mmitem_dim_combo (
|
||||
product_code TEXT NOT NULL,
|
||||
dim1 INTEGER NOT NULL,
|
||||
dim3 INTEGER,
|
||||
dim3_key INTEGER GENERATED ALWAYS AS (COALESCE(dim3, 0)) STORED,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT pk_mk_mmitem_dim_combo PRIMARY KEY (product_code, dim1, dim3_key)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_mmitem_dim_combo_product ON mk_mmitem_dim_combo (product_code, updated_at DESC)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_dim_token_map (
|
||||
dim_column TEXT NOT NULL, -- dimval1 or dimval3
|
||||
token TEXT NOT NULL, -- normalized token (e.g. "001", "82", etc.)
|
||||
dim_id INTEGER NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT pk_mk_dim_token_map PRIMARY KEY (dim_column, token)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_dim_token_map_updated ON mk_dim_token_map (updated_at DESC)`,
|
||||
}
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if _, err := pg.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedPricingTargetMapRows(pg, "mk_price_target_map_pg", "sdprcgrp_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedPricingTargetMapRows(pg, "mk_price_target_map_nebim", "price_group_code"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Repair invalid/missing pg target mappings after manual edits or table resets.
|
||||
// sdprcgrp_id is expected to be 1..6 in this installation.
|
||||
if _, err := pg.Exec(`
|
||||
UPDATE mk_price_target_map_pg
|
||||
SET sdprcgrp_id = level_no,
|
||||
updated_at = now()
|
||||
WHERE is_active = TRUE
|
||||
AND (sdprcgrp_id IS NULL OR sdprcgrp_id NOT BETWEEN 1 AND 6)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedPricingTargetMapRows(pg *sql.DB, tableName string, valueColumn string) error {
|
||||
currencies := []string{"TRY", "USD", "EUR"}
|
||||
for _, currency := range currencies {
|
||||
for level := 1; level <= 6; level++ {
|
||||
stmt := fmt.Sprintf(`
|
||||
INSERT INTO %s (currency, level_no, %s, description, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, NULL, '', TRUE, now(), now())
|
||||
ON CONFLICT (currency, level_no) DO NOTHING
|
||||
`, tableName, valueColumn)
|
||||
// PG targets: default sdprcgrp_id = level_no (1..6). This keeps sdprc writes valid after resets.
|
||||
if tableName == "mk_price_target_map_pg" && valueColumn == "sdprcgrp_id" {
|
||||
stmt = fmt.Sprintf(`
|
||||
INSERT INTO %s (currency, level_no, %s, description, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $2, '', TRUE, now(), now())
|
||||
ON CONFLICT (currency, level_no) DO NOTHING
|
||||
`, tableName, valueColumn)
|
||||
}
|
||||
if valueColumn == "price_group_code" {
|
||||
stmt = fmt.Sprintf(`
|
||||
INSERT INTO %s (currency, level_no, %s, description, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, '', '', TRUE, now(), now())
|
||||
ON CONFLICT (currency, level_no) DO NOTHING
|
||||
`, tableName, valueColumn)
|
||||
}
|
||||
if _, err := pg.Exec(stmt, currency, level); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -196,7 +196,6 @@ SELECT
|
||||
icerik, marka, brand_code, brand_group_sec, scope_key
|
||||
FROM mk_urunpricingprmtr
|
||||
WHERE id=$1
|
||||
AND is_active=TRUE
|
||||
`, pricingParameterID).Scan(
|
||||
&p.AskiliYan,
|
||||
&p.Kategori,
|
||||
@@ -441,6 +440,12 @@ WHERE ProductAtt42 IN ('SERI', 'AKSESUAR')
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Serialize writes touching mk_urunpricingprmtr/mk_pricing_rule/mk_pricex/mk_priceroll
|
||||
// to avoid deadlocks with bulk-save/import flows.
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
CREATE TEMP TABLE tmp_urunpricingprmtr_sync (
|
||||
askili_yan TEXT NOT NULL,
|
||||
@@ -714,6 +719,17 @@ SELECT
|
||||
p.brand_code,
|
||||
p.brand_group_sec,
|
||||
COALESCE(r.id::text, ''),
|
||||
COALESCE(
|
||||
r.strategy_code,
|
||||
CASE
|
||||
WHEN UPPER(BTRIM(p.brand_group_sec)) IN ('CORE','PREMIUM','SARTORIAL') THEN UPPER(BTRIM(p.brand_group_sec))
|
||||
ELSE 'CORE'
|
||||
END
|
||||
),
|
||||
COALESCE(r.anchor_mode, bg.anchor_mode, 'USD'),
|
||||
COALESCE(r.calc_enabled, TRUE),
|
||||
COALESCE(r.publish_postgres, TRUE),
|
||||
COALESCE(r.publish_nebim, TRUE),
|
||||
COALESCE(r.is_active, TRUE),
|
||||
|
||||
COALESCE(tx.base_mult, 0)::float8,
|
||||
@@ -725,6 +741,7 @@ SELECT
|
||||
COALESCE(tx.m6, 0)::float8,
|
||||
COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8,
|
||||
COALESCE(NULLIF(tr.retail_step, 0), tr.step, 0)::float8,
|
||||
COALESCE(NULLIF(BTRIM(tr.retail_mode), ''), 'STEP'),
|
||||
|
||||
COALESCE(ux.base_mult, 0)::float8,
|
||||
COALESCE(ux.m1, 0)::float8,
|
||||
@@ -735,6 +752,7 @@ SELECT
|
||||
COALESCE(ux.m6, 0)::float8,
|
||||
COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8,
|
||||
COALESCE(NULLIF(ur.retail_step, 0), ur.step, 0)::float8,
|
||||
COALESCE(NULLIF(BTRIM(ur.retail_mode), ''), 'STEP'),
|
||||
|
||||
COALESCE(ex.base_mult, 0)::float8,
|
||||
COALESCE(ex.m1, 0)::float8,
|
||||
@@ -744,7 +762,8 @@ SELECT
|
||||
COALESCE(ex.m5, 0)::float8,
|
||||
COALESCE(ex.m6, 0)::float8,
|
||||
COALESCE(NULLIF(er.wholesale_step, 0), er.step, 0)::float8,
|
||||
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8
|
||||
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8,
|
||||
COALESCE(NULLIF(BTRIM(er.retail_mode), ''), 'STEP')
|
||||
FROM mk_urunpricingprmtr p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT latest_rule.*
|
||||
@@ -753,6 +772,14 @@ LEFT JOIN LATERAL (
|
||||
ORDER BY latest_rule.created_at DESC, latest_rule.updated_at DESC, latest_rule.id DESC
|
||||
LIMIT 1
|
||||
) r ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT g.anchor_mode
|
||||
FROM mk_brandgrp g
|
||||
WHERE UPPER(BTRIM(g.code)) = UPPER(BTRIM(p.brand_group_sec))
|
||||
OR UPPER(BTRIM(g.title)) = UPPER(BTRIM(p.brand_group_sec))
|
||||
ORDER BY g.id
|
||||
LIMIT 1
|
||||
) bg ON TRUE
|
||||
LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY'
|
||||
LEFT JOIN mk_pricex ux ON ux.rule_id = r.id AND ux.currency='USD'
|
||||
LEFT JOIN mk_pricex ex ON ex.rule_id = r.id AND ex.currency='EUR'
|
||||
@@ -790,13 +817,21 @@ ORDER BY
|
||||
&item.BrandCode,
|
||||
&item.BrandGroupSec,
|
||||
&rule.ID,
|
||||
&rule.StrategyCode,
|
||||
&rule.AnchorMode,
|
||||
&rule.CalcEnabled,
|
||||
&rule.PublishPostgres,
|
||||
&rule.PublishNebim,
|
||||
&rule.IsActive,
|
||||
&rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryWholesaleStep, &rule.TryRetailStep,
|
||||
&rule.UsdBase, &rule.Usd1, &rule.Usd2, &rule.Usd3, &rule.Usd4, &rule.Usd5, &rule.Usd6, &rule.UsdWholesaleStep, &rule.UsdRetailStep,
|
||||
&rule.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurWholesaleStep, &rule.EurRetailStep,
|
||||
&rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryWholesaleStep, &rule.TryRetailStep, &rule.TryRetailMode,
|
||||
&rule.UsdBase, &rule.Usd1, &rule.Usd2, &rule.Usd3, &rule.Usd4, &rule.Usd5, &rule.Usd6, &rule.UsdWholesaleStep, &rule.UsdRetailStep, &rule.UsdRetailMode,
|
||||
&rule.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurWholesaleStep, &rule.EurRetailStep, &rule.EurRetailMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule.TryRetailMode = normalizeRetailMode(rule.TryRetailMode)
|
||||
rule.UsdRetailMode = normalizeRetailMode(rule.UsdRetailMode)
|
||||
rule.EurRetailMode = normalizeRetailMode(rule.EurRetailMode)
|
||||
rule.PricingParameterID = item.PricingParameterID
|
||||
rule.AskiliYan = pricingParameterScopeValue(item.AskiliYan)
|
||||
rule.Kategori = pricingParameterScopeValue(item.Kategori)
|
||||
@@ -809,9 +844,7 @@ ORDER BY
|
||||
rule.BrandCode = pricingParameterScopeValue(item.BrandCode)
|
||||
rule.BrandGroupSec = pricingParameterScopeValue(item.BrandGroupSec)
|
||||
item.HasRule = strings.TrimSpace(rule.ID) != ""
|
||||
if item.HasRule {
|
||||
item.Rule = &rule
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
@@ -3,6 +3,7 @@ package queries
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -15,6 +16,22 @@ import (
|
||||
// - mk_pricex: per-currency multipliers (base + 1..6).
|
||||
// - mk_priceroll: per-currency rounding steps for wholesale (1-5) and retail (6+).
|
||||
|
||||
func normalizeRetailMode(v string) string {
|
||||
v = strings.ToUpper(strings.TrimSpace(v))
|
||||
switch v {
|
||||
case "", "STEP":
|
||||
return "STEP"
|
||||
case "END_99", "END_49", "BAND_99", "BAND_49":
|
||||
return v
|
||||
default:
|
||||
return "STEP"
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeRetailModeForRoute(v string) string {
|
||||
return normalizeRetailMode(v)
|
||||
}
|
||||
|
||||
func EnsurePricingRuleTables(pg *sql.DB) error {
|
||||
stmts := []string{
|
||||
`
|
||||
@@ -32,10 +49,26 @@ CREATE TABLE IF NOT EXISTS mk_pricing_rule (
|
||||
brand_code TEXT[] NOT NULL DEFAULT '{}'::text[],
|
||||
brand_group TEXT[] NOT NULL DEFAULT '{}'::text[],
|
||||
|
||||
strategy_code TEXT NOT NULL DEFAULT 'CORE',
|
||||
anchor_mode TEXT NOT NULL DEFAULT 'USD',
|
||||
calc_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
publish_postgres BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
publish_nebim BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS strategy_code TEXT NOT NULL DEFAULT 'CORE'`,
|
||||
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS anchor_mode TEXT NOT NULL DEFAULT 'USD'`,
|
||||
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS calc_enabled BOOLEAN NOT NULL DEFAULT TRUE`,
|
||||
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS publish_postgres BOOLEAN NOT NULL DEFAULT TRUE`,
|
||||
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS publish_nebim BOOLEAN NOT NULL DEFAULT TRUE`,
|
||||
`UPDATE mk_pricing_rule SET strategy_code='CORE' WHERE COALESCE(NULLIF(BTRIM(strategy_code), ''), '') = ''`,
|
||||
`UPDATE mk_pricing_rule SET anchor_mode='USD' WHERE COALESCE(NULLIF(BTRIM(anchor_mode), ''), '') = ''`,
|
||||
`ALTER TABLE mk_pricing_rule DROP CONSTRAINT IF EXISTS ck_mk_pricing_rule_strategy_code`,
|
||||
`ALTER TABLE mk_pricing_rule ADD CONSTRAINT ck_mk_pricing_rule_strategy_code CHECK (strategy_code IN ('CORE','PREMIUM','SARTORIAL'))`,
|
||||
`ALTER TABLE mk_pricing_rule DROP CONSTRAINT IF EXISTS ck_mk_pricing_rule_anchor_mode`,
|
||||
`ALTER TABLE mk_pricing_rule ADD CONSTRAINT ck_mk_pricing_rule_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_pricing_rule_active ON mk_pricing_rule (is_active)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_pricex (
|
||||
@@ -60,13 +93,16 @@ CREATE TABLE IF NOT EXISTS mk_priceroll (
|
||||
step NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
wholesale_step NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
retail_step NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
retail_mode TEXT NOT NULL DEFAULT 'STEP',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (rule_id, currency)
|
||||
)`,
|
||||
`ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS wholesale_step NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS retail_step NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS retail_mode TEXT NOT NULL DEFAULT 'STEP'`,
|
||||
`UPDATE mk_priceroll SET wholesale_step = step, retail_step = step WHERE step <> 0 AND wholesale_step = 0 AND retail_step = 0`,
|
||||
`UPDATE mk_priceroll SET retail_mode='STEP' WHERE COALESCE(NULLIF(BTRIM(retail_mode), ''), '') = ''`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_priceroll_currency ON mk_priceroll (currency)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
@@ -92,6 +128,11 @@ type PricingRuleRow struct {
|
||||
BrandCode []string `json:"brand_code"`
|
||||
BrandGroupSec []string `json:"brand_group"`
|
||||
|
||||
StrategyCode string `json:"strategy_code"`
|
||||
AnchorMode string `json:"anchor_mode"`
|
||||
CalcEnabled bool `json:"calc_enabled"`
|
||||
PublishPostgres bool `json:"publish_postgres"`
|
||||
PublishNebim bool `json:"publish_nebim"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
// multipliers/rolls are per currency
|
||||
@@ -104,6 +145,7 @@ type PricingRuleRow struct {
|
||||
Try6 float64 `json:"try6"`
|
||||
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
||||
TryRetailStep float64 `json:"try_retail_step"`
|
||||
TryRetailMode string `json:"try_retail_mode"`
|
||||
|
||||
UsdBase float64 `json:"usd_base"`
|
||||
Usd1 float64 `json:"usd1"`
|
||||
@@ -114,6 +156,7 @@ type PricingRuleRow struct {
|
||||
Usd6 float64 `json:"usd6"`
|
||||
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
||||
UsdRetailStep float64 `json:"usd_retail_step"`
|
||||
UsdRetailMode string `json:"usd_retail_mode"`
|
||||
|
||||
EurBase float64 `json:"eur_base"`
|
||||
Eur1 float64 `json:"eur1"`
|
||||
@@ -124,6 +167,7 @@ type PricingRuleRow struct {
|
||||
Eur6 float64 `json:"eur6"`
|
||||
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
||||
EurRetailStep float64 `json:"eur_retail_step"`
|
||||
EurRetailMode string `json:"eur_retail_mode"`
|
||||
}
|
||||
|
||||
type PricingRuleSaveItem struct {
|
||||
@@ -141,6 +185,11 @@ type PricingRuleSaveItem struct {
|
||||
BrandCode []string `json:"brand_code"`
|
||||
BrandGroupSec []string `json:"brand_group"`
|
||||
|
||||
StrategyCode string `json:"strategy_code"`
|
||||
AnchorMode string `json:"anchor_mode"`
|
||||
CalcEnabled bool `json:"calc_enabled"`
|
||||
PublishPostgres bool `json:"publish_postgres"`
|
||||
PublishNebim bool `json:"publish_nebim"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
TryBase float64 `json:"try_base"`
|
||||
@@ -152,6 +201,7 @@ type PricingRuleSaveItem struct {
|
||||
Try6 float64 `json:"try6"`
|
||||
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
||||
TryRetailStep float64 `json:"try_retail_step"`
|
||||
TryRetailMode string `json:"try_retail_mode"`
|
||||
|
||||
UsdBase float64 `json:"usd_base"`
|
||||
Usd1 float64 `json:"usd1"`
|
||||
@@ -162,6 +212,7 @@ type PricingRuleSaveItem struct {
|
||||
Usd6 float64 `json:"usd6"`
|
||||
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
||||
UsdRetailStep float64 `json:"usd_retail_step"`
|
||||
UsdRetailMode string `json:"usd_retail_mode"`
|
||||
|
||||
EurBase float64 `json:"eur_base"`
|
||||
Eur1 float64 `json:"eur1"`
|
||||
@@ -172,6 +223,174 @@ type PricingRuleSaveItem struct {
|
||||
Eur6 float64 `json:"eur6"`
|
||||
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
||||
EurRetailStep float64 `json:"eur_retail_step"`
|
||||
EurRetailMode string `json:"eur_retail_mode"`
|
||||
}
|
||||
|
||||
// BulkSavePricingRulesFast persists multipliers + rounding steps in a set-based way.
|
||||
// This is intentionally "dumb": it updates/creates a mk_pricing_rule row (latest by pricing_parameter_id)
|
||||
// and upserts mk_pricex/mk_priceroll for TRY/USD/EUR.
|
||||
func BulkSavePricingRulesFast(ctx context.Context, tx *sql.Tx, items []PricingRuleSaveItem) (int, error) {
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Notes:
|
||||
// - rule_id resolution:
|
||||
// 1) explicit id (if provided)
|
||||
// 2) latest rule for pricing_parameter_id (if provided)
|
||||
// 3) otherwise new UUID
|
||||
// - mk_pricing_rule has no unique constraint on pricing_parameter_id by design, so we target "latest" row.
|
||||
// - created_at uses default; updated_at is bumped on every save.
|
||||
q := `
|
||||
WITH input AS (
|
||||
SELECT *
|
||||
FROM jsonb_to_recordset($1::jsonb) AS x(
|
||||
id text,
|
||||
pricing_parameter_id bigint,
|
||||
calc_enabled boolean,
|
||||
publish_postgres boolean,
|
||||
publish_nebim boolean,
|
||||
is_active boolean,
|
||||
try_retail_mode text,
|
||||
usd_retail_mode text,
|
||||
eur_retail_mode text,
|
||||
|
||||
try_base float8, try1 float8, try2 float8, try3 float8, try4 float8, try5 float8, try6 float8,
|
||||
try_wholesale_step float8, try_retail_step float8,
|
||||
|
||||
usd_base float8, usd1 float8, usd2 float8, usd3 float8, usd4 float8, usd5 float8, usd6 float8,
|
||||
usd_wholesale_step float8, usd_retail_step float8,
|
||||
|
||||
eur_base float8, eur1 float8, eur2 float8, eur3 float8, eur4 float8, eur5 float8, eur6 float8,
|
||||
eur_wholesale_step float8, eur_retail_step float8
|
||||
)
|
||||
),
|
||||
norm AS (
|
||||
SELECT
|
||||
NULLIF(BTRIM(id), '') AS id_txt,
|
||||
COALESCE(pricing_parameter_id, 0) AS pricing_parameter_id,
|
||||
COALESCE(calc_enabled, TRUE) AS calc_enabled,
|
||||
COALESCE(publish_postgres, TRUE) AS publish_postgres,
|
||||
COALESCE(publish_nebim, TRUE) AS publish_nebim,
|
||||
COALESCE(is_active, TRUE) AS is_active,
|
||||
COALESCE(NULLIF(UPPER(BTRIM(try_retail_mode)), ''), 'STEP') AS try_retail_mode,
|
||||
COALESCE(NULLIF(UPPER(BTRIM(usd_retail_mode)), ''), 'STEP') AS usd_retail_mode,
|
||||
COALESCE(NULLIF(UPPER(BTRIM(eur_retail_mode)), ''), 'STEP') AS eur_retail_mode,
|
||||
|
||||
COALESCE(try_base, 0) AS try_base, COALESCE(try1, 0) AS try1, COALESCE(try2, 0) AS try2, COALESCE(try3, 0) AS try3, COALESCE(try4, 0) AS try4, COALESCE(try5, 0) AS try5, COALESCE(try6, 0) AS try6,
|
||||
COALESCE(try_wholesale_step, 0) AS try_wholesale_step, COALESCE(try_retail_step, 0) AS try_retail_step,
|
||||
|
||||
COALESCE(usd_base, 0) AS usd_base, COALESCE(usd1, 0) AS usd1, COALESCE(usd2, 0) AS usd2, COALESCE(usd3, 0) AS usd3, COALESCE(usd4, 0) AS usd4, COALESCE(usd5, 0) AS usd5, COALESCE(usd6, 0) AS usd6,
|
||||
COALESCE(usd_wholesale_step, 0) AS usd_wholesale_step, COALESCE(usd_retail_step, 0) AS usd_retail_step,
|
||||
|
||||
COALESCE(eur_base, 0) AS eur_base, COALESCE(eur1, 0) AS eur1, COALESCE(eur2, 0) AS eur2, COALESCE(eur3, 0) AS eur3, COALESCE(eur4, 0) AS eur4, COALESCE(eur5, 0) AS eur5, COALESCE(eur6, 0) AS eur6,
|
||||
COALESCE(eur_wholesale_step, 0) AS eur_wholesale_step, COALESCE(eur_retail_step, 0) AS eur_retail_step
|
||||
FROM input
|
||||
),
|
||||
resolved AS (
|
||||
SELECT
|
||||
COALESCE(
|
||||
NULLIF(id_txt, '')::uuid,
|
||||
latest.id,
|
||||
gen_random_uuid()
|
||||
) AS rule_id,
|
||||
pricing_parameter_id,
|
||||
calc_enabled,
|
||||
publish_postgres,
|
||||
publish_nebim,
|
||||
is_active,
|
||||
try_retail_mode,
|
||||
usd_retail_mode,
|
||||
eur_retail_mode,
|
||||
|
||||
try_base, try1, try2, try3, try4, try5, try6,
|
||||
try_wholesale_step, try_retail_step,
|
||||
usd_base, usd1, usd2, usd3, usd4, usd5, usd6,
|
||||
usd_wholesale_step, usd_retail_step,
|
||||
eur_base, eur1, eur2, eur3, eur4, eur5, eur6,
|
||||
eur_wholesale_step, eur_retail_step
|
||||
FROM norm n
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT r.id
|
||||
FROM mk_pricing_rule r
|
||||
WHERE r.pricing_parameter_id = n.pricing_parameter_id
|
||||
ORDER BY r.created_at DESC, r.updated_at DESC, r.id DESC
|
||||
LIMIT 1
|
||||
) latest ON (n.id_txt IS NULL AND n.pricing_parameter_id > 0)
|
||||
),
|
||||
upsert_rule AS (
|
||||
INSERT INTO mk_pricing_rule (
|
||||
id,
|
||||
pricing_parameter_id,
|
||||
calc_enabled,
|
||||
publish_postgres,
|
||||
publish_nebim,
|
||||
is_active,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
rule_id,
|
||||
NULLIF(pricing_parameter_id, 0),
|
||||
calc_enabled,
|
||||
publish_postgres,
|
||||
publish_nebim,
|
||||
is_active,
|
||||
now()
|
||||
FROM resolved
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
pricing_parameter_id = EXCLUDED.pricing_parameter_id,
|
||||
calc_enabled = EXCLUDED.calc_enabled,
|
||||
publish_postgres = EXCLUDED.publish_postgres,
|
||||
publish_nebim = EXCLUDED.publish_nebim,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = now()
|
||||
RETURNING id
|
||||
),
|
||||
upsert_pricex AS (
|
||||
INSERT INTO mk_pricex (rule_id, currency, base_mult, m1, m2, m3, m4, m5, m6, updated_at)
|
||||
SELECT rule_id, 'TRY', try_base, try1, try2, try3, try4, try5, try6, now() FROM resolved
|
||||
UNION ALL
|
||||
SELECT rule_id, 'USD', usd_base, usd1, usd2, usd3, usd4, usd5, usd6, now() FROM resolved
|
||||
UNION ALL
|
||||
SELECT rule_id, 'EUR', eur_base, eur1, eur2, eur3, eur4, eur5, eur6, now() FROM resolved
|
||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
base_mult = EXCLUDED.base_mult,
|
||||
m1 = EXCLUDED.m1,
|
||||
m2 = EXCLUDED.m2,
|
||||
m3 = EXCLUDED.m3,
|
||||
m4 = EXCLUDED.m4,
|
||||
m5 = EXCLUDED.m5,
|
||||
m6 = EXCLUDED.m6,
|
||||
updated_at = now()
|
||||
RETURNING 1
|
||||
),
|
||||
upsert_priceroll AS (
|
||||
INSERT INTO mk_priceroll (rule_id, currency, wholesale_step, retail_step, retail_mode, updated_at)
|
||||
SELECT rule_id, 'TRY', try_wholesale_step, try_retail_step, try_retail_mode, now() FROM resolved
|
||||
UNION ALL
|
||||
SELECT rule_id, 'USD', usd_wholesale_step, usd_retail_step, usd_retail_mode, now() FROM resolved
|
||||
UNION ALL
|
||||
SELECT rule_id, 'EUR', eur_wholesale_step, eur_retail_step, eur_retail_mode, now() FROM resolved
|
||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
wholesale_step = EXCLUDED.wholesale_step,
|
||||
retail_step = EXCLUDED.retail_step,
|
||||
retail_mode = EXCLUDED.retail_mode,
|
||||
updated_at = now()
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*)::int FROM resolved;
|
||||
`
|
||||
|
||||
var updated int
|
||||
if err := tx.QueryRowContext(ctx, q, raw).Scan(&updated); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func ListPricingRules(ctx context.Context, pg *sql.DB) ([]PricingRuleRow, error) {
|
||||
@@ -190,6 +409,11 @@ SELECT
|
||||
r.marka,
|
||||
r.brand_code,
|
||||
r.brand_group,
|
||||
r.strategy_code,
|
||||
r.anchor_mode,
|
||||
r.calc_enabled,
|
||||
r.publish_postgres,
|
||||
r.publish_nebim,
|
||||
r.is_active,
|
||||
|
||||
COALESCE(tx.base_mult, 0)::float8 AS try_base,
|
||||
@@ -201,6 +425,7 @@ SELECT
|
||||
COALESCE(tx.m6, 0)::float8 AS try6,
|
||||
COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8 AS try_wholesale_step,
|
||||
COALESCE(NULLIF(tr.retail_step, 0), tr.step, 0)::float8 AS try_retail_step,
|
||||
COALESCE(NULLIF(BTRIM(tr.retail_mode), ''), 'STEP') AS try_retail_mode,
|
||||
|
||||
COALESCE(ux.base_mult, 0)::float8 AS usd_base,
|
||||
COALESCE(ux.m1, 0)::float8 AS usd1,
|
||||
@@ -211,6 +436,7 @@ SELECT
|
||||
COALESCE(ux.m6, 0)::float8 AS usd6,
|
||||
COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8 AS usd_wholesale_step,
|
||||
COALESCE(NULLIF(ur.retail_step, 0), ur.step, 0)::float8 AS usd_retail_step,
|
||||
COALESCE(NULLIF(BTRIM(ur.retail_mode), ''), 'STEP') AS usd_retail_mode,
|
||||
|
||||
COALESCE(ex.base_mult, 0)::float8 AS eur_base,
|
||||
COALESCE(ex.m1, 0)::float8 AS eur1,
|
||||
@@ -220,7 +446,8 @@ SELECT
|
||||
COALESCE(ex.m5, 0)::float8 AS eur5,
|
||||
COALESCE(ex.m6, 0)::float8 AS eur6,
|
||||
COALESCE(NULLIF(er.wholesale_step, 0), er.step, 0)::float8 AS eur_wholesale_step,
|
||||
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8 AS eur_retail_step
|
||||
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8 AS eur_retail_step,
|
||||
COALESCE(NULLIF(BTRIM(er.retail_mode), ''), 'STEP') AS eur_retail_mode
|
||||
FROM mk_pricing_rule r
|
||||
LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY'
|
||||
LEFT JOIN mk_pricex ux ON ux.rule_id = r.id AND ux.currency='USD'
|
||||
@@ -252,14 +479,22 @@ ORDER BY r.created_at DESC;
|
||||
pq.Array(&r.Marka),
|
||||
pq.Array(&r.BrandCode),
|
||||
pq.Array(&r.BrandGroupSec),
|
||||
&r.StrategyCode,
|
||||
&r.AnchorMode,
|
||||
&r.CalcEnabled,
|
||||
&r.PublishPostgres,
|
||||
&r.PublishNebim,
|
||||
&r.IsActive,
|
||||
|
||||
&r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryWholesaleStep, &r.TryRetailStep,
|
||||
&r.UsdBase, &r.Usd1, &r.Usd2, &r.Usd3, &r.Usd4, &r.Usd5, &r.Usd6, &r.UsdWholesaleStep, &r.UsdRetailStep,
|
||||
&r.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurWholesaleStep, &r.EurRetailStep,
|
||||
&r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryWholesaleStep, &r.TryRetailStep, &r.TryRetailMode,
|
||||
&r.UsdBase, &r.Usd1, &r.Usd2, &r.Usd3, &r.Usd4, &r.Usd5, &r.Usd6, &r.UsdWholesaleStep, &r.UsdRetailStep, &r.UsdRetailMode,
|
||||
&r.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurWholesaleStep, &r.EurRetailStep, &r.EurRetailMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.TryRetailMode = normalizeRetailMode(r.TryRetailMode)
|
||||
r.UsdRetailMode = normalizeRetailMode(r.UsdRetailMode)
|
||||
r.EurRetailMode = normalizeRetailMode(r.EurRetailMode)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -282,6 +517,42 @@ func normalizeTextList(in []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func deriveStrategyCodeFromBrandGroup(values []string) string {
|
||||
for _, value := range values {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "CORE", "PREMIUM", "SARTORIAL":
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return "CORE"
|
||||
}
|
||||
|
||||
func deriveAnchorModeFromBrandGroup(ctx context.Context, tx *sql.Tx, values []string) string {
|
||||
for _, value := range values {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
var mode string
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT anchor_mode
|
||||
FROM mk_brandgrp
|
||||
WHERE UPPER(BTRIM(code)) = UPPER(BTRIM($1))
|
||||
OR UPPER(BTRIM(title)) = UPPER(BTRIM($1))
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`, normalized).Scan(&mode)
|
||||
if err == nil {
|
||||
mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||
if mode == "TRY" || mode == "USD" {
|
||||
return mode
|
||||
}
|
||||
}
|
||||
}
|
||||
return "USD"
|
||||
}
|
||||
|
||||
// UpsertPricingRule persists rule scope + per-currency multipliers/roundings.
|
||||
// Parameter-backed worksheet saves append a new rule version so older prices
|
||||
// remain queryable. Legacy rules without a parameter id keep update behavior.
|
||||
@@ -306,6 +577,11 @@ func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem
|
||||
item.Marka = normalizeTextList(item.Marka)
|
||||
item.BrandCode = normalizeTextList(item.BrandCode)
|
||||
item.BrandGroupSec = normalizeTextList(item.BrandGroupSec)
|
||||
item.StrategyCode = deriveStrategyCodeFromBrandGroup(item.BrandGroupSec)
|
||||
item.AnchorMode = deriveAnchorModeFromBrandGroup(ctx, tx, item.BrandGroupSec)
|
||||
item.TryRetailMode = normalizeRetailMode(item.TryRetailMode)
|
||||
item.UsdRetailMode = normalizeRetailMode(item.UsdRetailMode)
|
||||
item.EurRetailMode = normalizeRetailMode(item.EurRetailMode)
|
||||
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if item.PricingParameterID > 0 {
|
||||
@@ -317,12 +593,15 @@ func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem
|
||||
INSERT INTO mk_pricing_rule (
|
||||
pricing_parameter_id,
|
||||
askili_yan,kategori,urun_ilk_grubu,urun_ana_grubu,urun_alt_grubu,
|
||||
icerik,karisim,marka,brand_code,brand_group,is_active,created_at,updated_at
|
||||
icerik,karisim,marka,brand_code,brand_group,
|
||||
strategy_code,anchor_mode,calc_enabled,publish_postgres,publish_nebim,
|
||||
is_active,created_at,updated_at
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now())
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,now(),now())
|
||||
RETURNING id
|
||||
`, nullablePricingParameterID(item.PricingParameterID), pq.Array(item.AskiliYan), pq.Array(item.Kategori), pq.Array(item.UrunIlkGrubu), pq.Array(item.UrunAnaGrubu), pq.Array(item.UrunAltGrubu),
|
||||
pq.Array(item.Icerik), pq.Array(item.Karisim), pq.Array(item.Marka), pq.Array(item.BrandCode), pq.Array(item.BrandGroupSec),
|
||||
item.StrategyCode, item.AnchorMode, item.CalcEnabled, item.PublishPostgres, item.PublishNebim,
|
||||
item.IsActive,
|
||||
).Scan(&id); err != nil {
|
||||
return "", err
|
||||
@@ -341,13 +620,19 @@ UPDATE mk_pricing_rule SET
|
||||
marka=$10,
|
||||
brand_code=$11,
|
||||
brand_group=$12,
|
||||
is_active=$13,
|
||||
strategy_code=$13,
|
||||
anchor_mode=$14,
|
||||
calc_enabled=$15,
|
||||
publish_postgres=$16,
|
||||
publish_nebim=$17,
|
||||
is_active=$18,
|
||||
updated_at=now()
|
||||
WHERE id=$1
|
||||
`, id,
|
||||
nullablePricingParameterID(item.PricingParameterID),
|
||||
pq.Array(item.AskiliYan), pq.Array(item.Kategori), pq.Array(item.UrunIlkGrubu), pq.Array(item.UrunAnaGrubu), pq.Array(item.UrunAltGrubu),
|
||||
pq.Array(item.Icerik), pq.Array(item.Karisim), pq.Array(item.Marka), pq.Array(item.BrandCode), pq.Array(item.BrandGroupSec),
|
||||
item.StrategyCode, item.AnchorMode, item.CalcEnabled, item.PublishPostgres, item.PublishNebim,
|
||||
item.IsActive,
|
||||
); err != nil {
|
||||
return "", err
|
||||
@@ -371,41 +656,176 @@ ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
`, id, cur, base, m1, m2, m3, m4, m5, m6)
|
||||
return err
|
||||
}
|
||||
upsertRoll := func(cur string, wholesaleStep, retailStep float64) error {
|
||||
upsertRoll := func(cur string, wholesaleStep, retailStep float64, retailMode string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,now(),now())
|
||||
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, retail_mode, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,now(),now())
|
||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
step=EXCLUDED.step,
|
||||
wholesale_step=EXCLUDED.wholesale_step,
|
||||
retail_step=EXCLUDED.retail_step,
|
||||
retail_mode=EXCLUDED.retail_mode,
|
||||
updated_at=now()
|
||||
`, id, cur, wholesaleStep, wholesaleStep, retailStep)
|
||||
`, id, cur, wholesaleStep, wholesaleStep, retailStep, retailMode)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep); err != nil {
|
||||
if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep, item.TryRetailMode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep); err != nil {
|
||||
if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep, item.UsdRetailMode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep); err != nil {
|
||||
if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep, item.EurRetailMode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdatePricingRuleByIDFast updates an existing rule without parameter versioning/scope fill.
|
||||
// This is the fast path for worksheet saves where rule_id is already known.
|
||||
func UpdatePricingRuleByIDFast(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem) error {
|
||||
if tx == nil {
|
||||
return fmt.Errorf("nil tx")
|
||||
}
|
||||
ruleID := strings.TrimSpace(item.ID)
|
||||
if ruleID == "" {
|
||||
return fmt.Errorf("missing rule id")
|
||||
}
|
||||
|
||||
item.TryRetailMode = normalizeRetailMode(item.TryRetailMode)
|
||||
item.UsdRetailMode = normalizeRetailMode(item.UsdRetailMode)
|
||||
item.EurRetailMode = normalizeRetailMode(item.EurRetailMode)
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_pricing_rule SET
|
||||
calc_enabled=$2,
|
||||
publish_postgres=$3,
|
||||
publish_nebim=$4,
|
||||
is_active=$5,
|
||||
updated_at=now()
|
||||
WHERE id=$1
|
||||
`, ruleID, item.CalcEnabled, item.PublishPostgres, item.PublishNebim, item.IsActive); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
upsertX := func(cur string, base, m1, m2, m3, m4, m5, m6 float64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_pricex (rule_id, currency, base_mult, m1, m2, m3, m4, m5, m6, created_at, updated_at)
|
||||
VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,$7,$8,$9,now(),now())
|
||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
base_mult=EXCLUDED.base_mult,
|
||||
m1=EXCLUDED.m1,
|
||||
m2=EXCLUDED.m2,
|
||||
m3=EXCLUDED.m3,
|
||||
m4=EXCLUDED.m4,
|
||||
m5=EXCLUDED.m5,
|
||||
m6=EXCLUDED.m6,
|
||||
updated_at=now()
|
||||
`, ruleID, cur, base, m1, m2, m3, m4, m5, m6)
|
||||
return err
|
||||
}
|
||||
|
||||
upsertRoll := func(cur string, wholesaleStep, retailStep float64, retailMode string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, retail_mode, created_at, updated_at)
|
||||
VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,now(),now())
|
||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
step=EXCLUDED.step,
|
||||
wholesale_step=EXCLUDED.wholesale_step,
|
||||
retail_step=EXCLUDED.retail_step,
|
||||
retail_mode=EXCLUDED.retail_mode,
|
||||
updated_at=now()
|
||||
`, ruleID, cur, wholesaleStep, wholesaleStep, retailStep, retailMode)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep, item.TryRetailMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep, item.UsdRetailMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep, item.EurRetailMode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertPricingRuleByParameterIDFast ensures there is a rule row for a pricing_parameter_id and
|
||||
// updates its multipliers/roundings in place. This avoids expensive parameter versioning and
|
||||
// scope fill during worksheet-style bulk saves.
|
||||
func UpsertPricingRuleByParameterIDFast(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem) (string, error) {
|
||||
if tx == nil {
|
||||
return "", fmt.Errorf("nil tx")
|
||||
}
|
||||
if item.PricingParameterID <= 0 {
|
||||
return "", fmt.Errorf("missing pricing_parameter_id")
|
||||
}
|
||||
|
||||
// Find latest rule for this parameter id (if any).
|
||||
var ruleID string
|
||||
_ = tx.QueryRowContext(ctx, `
|
||||
SELECT id::text
|
||||
FROM mk_pricing_rule
|
||||
WHERE pricing_parameter_id = $1
|
||||
ORDER BY created_at DESC, updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`, item.PricingParameterID).Scan(&ruleID)
|
||||
ruleID = strings.TrimSpace(ruleID)
|
||||
|
||||
if ruleID == "" {
|
||||
// Create minimal rule row; other fields have defaults and parameter scope is read from mk_urunpricingprmtr.
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO mk_pricing_rule (
|
||||
pricing_parameter_id,
|
||||
calc_enabled,
|
||||
publish_postgres,
|
||||
publish_nebim,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,now(),now())
|
||||
RETURNING id::text
|
||||
`, item.PricingParameterID, item.CalcEnabled, item.PublishPostgres, item.PublishNebim, item.IsActive).Scan(&ruleID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ruleID = strings.TrimSpace(ruleID)
|
||||
}
|
||||
if ruleID == "" {
|
||||
return "", fmt.Errorf("failed to resolve rule id")
|
||||
}
|
||||
|
||||
// Reuse the ID-fast updater now that we have an id.
|
||||
item.ID = ruleID
|
||||
if err := UpdatePricingRuleByIDFast(ctx, tx, item); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ruleID, nil
|
||||
}
|
||||
|
||||
func nullablePricingParameterID(id int64) any {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -115,6 +115,11 @@ func GetAllProductPricingRows(ctx context.Context, chunkSize int, filters Produc
|
||||
orderExpr = "rc.ProductCode"
|
||||
orderDir = "ASC"
|
||||
}
|
||||
orderBySQL := orderExpr + ` ` + orderDir
|
||||
if !strings.EqualFold(strings.TrimSpace(orderExpr), "rc.ProductCode") {
|
||||
orderBySQL += `,
|
||||
rc.ProductCode ASC`
|
||||
}
|
||||
|
||||
baseQuery := `
|
||||
IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes;
|
||||
@@ -230,8 +235,7 @@ func GetAllProductPricingRows(ctx context.Context, chunkSize int, filters Produc
|
||||
LEFT JOIN #disp_base db
|
||||
ON db.ItemCode = rc.ProductCode
|
||||
ORDER BY
|
||||
` + orderExpr + ` ` + orderDir + `,
|
||||
rc.ProductCode ASC;
|
||||
` + orderBySQL + `;
|
||||
`
|
||||
|
||||
rows, err := db.MssqlDB.QueryContext(ctx, baseQuery, args...)
|
||||
@@ -740,6 +744,11 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
|
||||
orderExpr = "rc.ProductCode"
|
||||
orderDir = "ASC"
|
||||
}
|
||||
orderBySQL := orderExpr + ` ` + orderDir
|
||||
if !strings.EqualFold(strings.TrimSpace(orderExpr), "rc.ProductCode") {
|
||||
orderBySQL += `,
|
||||
rc.ProductCode ASC`
|
||||
}
|
||||
productQuery := `
|
||||
IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes;
|
||||
IF OBJECT_ID('tempdb..#stock_base') IS NOT NULL DROP TABLE #stock_base;
|
||||
@@ -806,8 +815,7 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
|
||||
LEFT JOIN #stock_base sb
|
||||
ON sb.ItemCode = rc.ProductCode
|
||||
ORDER BY
|
||||
` + orderExpr + ` ` + orderDir + `,
|
||||
rc.ProductCode ASC
|
||||
` + orderBySQL + `
|
||||
OFFSET ` + strconv.Itoa(offset) + ` ROWS
|
||||
FETCH NEXT ` + strconv.Itoa(limit) + ` ROWS ONLY;
|
||||
`
|
||||
|
||||
25
svc/queries/product_pricing_dims_mssql.go
Normal file
25
svc/queries/product_pricing_dims_mssql.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package queries
|
||||
|
||||
// GetProductVariantDimsForPricing:
|
||||
// Pull variant dimension combos from Nebim stock tables (same source as product-stock-query UI).
|
||||
// We intentionally keep it small: only the keys we need to write dim-aware prices into PG sdprc.
|
||||
//
|
||||
// Note: Column semantics depend on your Nebim setup. We treat ItemDim1Code/ItemDim3Code as the
|
||||
// primary variant dimensions used by the e-commerce sdprc dim filters.
|
||||
const GetProductVariantDimsForPricing = `
|
||||
DECLARE @ProductCode NVARCHAR(50) = @p1;
|
||||
|
||||
SELECT DISTINCT
|
||||
LTRIM(RTRIM(ISNULL(S.ColorCode,''))) AS ColorCode,
|
||||
LTRIM(RTRIM(ISNULL(S.ItemDim1Code,''))) AS ItemDim1Code,
|
||||
LTRIM(RTRIM(ISNULL(S.ItemDim3Code,''))) AS ItemDim3Code
|
||||
FROM trStock S WITH(NOLOCK)
|
||||
WHERE S.ItemTypeCode = 1
|
||||
AND S.ItemCode = @ProductCode
|
||||
AND LEN(S.ItemCode) = 13
|
||||
AND LEN(@ProductCode) = 13
|
||||
ORDER BY
|
||||
LTRIM(RTRIM(ISNULL(S.ColorCode,''))),
|
||||
LTRIM(RTRIM(ISNULL(S.ItemDim1Code,''))),
|
||||
LTRIM(RTRIM(ISNULL(S.ItemDim3Code,'')));
|
||||
`
|
||||
362
svc/queries/product_pricing_fx_publish.go
Normal file
362
svc/queries/product_pricing_fx_publish.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"bssapp-backend/models"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FxDeltaPublishStats struct {
|
||||
RateDate string
|
||||
|
||||
Queued int
|
||||
Updated int // sdprc rows updated/inserted
|
||||
Skipped int // missing anchor or rule
|
||||
Failures int
|
||||
}
|
||||
|
||||
type sdprcPublishRow struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
Currency string `json:"currency"`
|
||||
LevelNo int `json:"level_no"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
func round2fx(v float64) float64 {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return 0
|
||||
}
|
||||
return math.Round(v*100) / 100
|
||||
}
|
||||
|
||||
func roundDerivedWithRule(rule *PricingRuleRow, currency string, level int, raw float64) float64 {
|
||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
||||
if level < 1 || level > 6 {
|
||||
return 0
|
||||
}
|
||||
if rule == nil {
|
||||
// Fallback: keep a stable 2-decimal behavior when no rule exists.
|
||||
return round2fx(raw)
|
||||
}
|
||||
|
||||
whStep := 0.0
|
||||
rtStep := 0.0
|
||||
rtMode := ""
|
||||
switch currency {
|
||||
case "TRY":
|
||||
whStep, rtStep, rtMode = rule.TryWholesaleStep, rule.TryRetailStep, rule.TryRetailMode
|
||||
case "USD":
|
||||
whStep, rtStep, rtMode = rule.UsdWholesaleStep, rule.UsdRetailStep, rule.UsdRetailMode
|
||||
case "EUR":
|
||||
whStep, rtStep, rtMode = rule.EurWholesaleStep, rule.EurRetailStep, rule.EurRetailMode
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
// In our model: level 1-5 = wholesale rounding, level 6 = retail rounding.
|
||||
if level >= 6 {
|
||||
return applyRetailRounding(raw, whStep, rtStep, rtMode)
|
||||
}
|
||||
return roundUpStep(raw, whStep)
|
||||
}
|
||||
|
||||
// PublishDerivedPricesFromAnchor recalculates derived currency tiers from the stored anchor tiers in sdprc.
|
||||
// Rule selection determines anchor_mode (USD/TRY). Anchor tiers are never modified here.
|
||||
func PublishDerivedPricesFromAnchor(ctx context.Context, pg *sql.DB, productCodes []string, rateDate string, forceFxRefresh bool) (int, int, error) {
|
||||
if len(productCodes) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
rateRow, err := resolvePricingFxRateByDate(ctx, pg, rateDate, forceFxRefresh, true)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if rateRow.UsdTry <= 0 || rateRow.EurTry <= 0 || rateRow.UsdEur <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid fx rates for date %s", rateRow.RateDate)
|
||||
}
|
||||
|
||||
// Load rule map once.
|
||||
ruleRows, err := ListPricingParameterRules(ctx, pg, PricingRuleOptionFilters{})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
rulesByScope := make(map[string]PricingParameterRuleRow, len(ruleRows))
|
||||
for _, item := range ruleRows {
|
||||
rulesByScope[item.ScopeKey] = item
|
||||
}
|
||||
|
||||
// Fetch product metadata (scope) from MSSQL.
|
||||
products, err := GetAllProductPricingRows(ctx, 1000, ProductPricingFilters{ProductCode: productCodes}, "productCode", false)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
byCode := make(map[string]models.ProductPricing, len(products))
|
||||
for _, p := range products {
|
||||
code := strings.TrimSpace(p.ProductCode)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
byCode[code] = p
|
||||
}
|
||||
|
||||
derivedTargets := make([]sdprcPublishRow, 0, len(productCodes)*12) // derived: 2 currencies * 6 levels
|
||||
skipped := 0
|
||||
|
||||
for _, codeRaw := range productCodes {
|
||||
code := strings.TrimSpace(codeRaw)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
product, ok := byCode[code]
|
||||
if !ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
scopeKey := pricingParameterScopeKey(pricingParameterRow{
|
||||
AskiliYan: strings.TrimSpace(product.AskiliYan),
|
||||
Kategori: strings.TrimSpace(product.Kategori),
|
||||
UrunIlkGrubu: strings.TrimSpace(product.UrunIlkGrubu),
|
||||
UrunAnaGrubu: strings.TrimSpace(product.UrunAnaGrubu),
|
||||
UrunAltGrubu: strings.TrimSpace(product.UrunAltGrubu),
|
||||
Icerik: strings.TrimSpace(product.Icerik),
|
||||
Marka: strings.TrimSpace(product.Marka),
|
||||
BrandCode: strings.TrimSpace(product.BrandCode),
|
||||
BrandGroupSec: strings.TrimSpace(product.BrandGroupSec),
|
||||
})
|
||||
ruleItem, ok := rulesByScope[scopeKey]
|
||||
if !ok || ruleItem.Rule == nil || !ruleItem.Rule.IsActive || !ruleItem.Rule.CalcEnabled {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
anchorMode := strings.ToUpper(strings.TrimSpace(ruleItem.Rule.AnchorMode))
|
||||
if anchorMode != "USD" && anchorMode != "TRY" {
|
||||
anchorMode = "USD"
|
||||
}
|
||||
|
||||
anchor, ok, err := loadLatestSdprcTiers(ctx, pg, code, anchorMode)
|
||||
if err != nil || !ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
switch anchorMode {
|
||||
case "USD":
|
||||
for i := 0; i < 6; i++ {
|
||||
level := i + 1
|
||||
usd := anchor[i]
|
||||
tryV := roundDerivedWithRule(ruleItem.Rule, "TRY", level, usd*rateRow.UsdTry)
|
||||
eurV := roundDerivedWithRule(ruleItem.Rule, "EUR", level, usd*rateRow.UsdEur)
|
||||
if tryV > 0 {
|
||||
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "TRY", LevelNo: level, Price: tryV})
|
||||
}
|
||||
if eurV > 0 {
|
||||
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "EUR", LevelNo: level, Price: eurV})
|
||||
}
|
||||
}
|
||||
default: // TRY
|
||||
for i := 0; i < 6; i++ {
|
||||
level := i + 1
|
||||
tryV := anchor[i]
|
||||
usd := roundDerivedWithRule(ruleItem.Rule, "USD", level, tryV/rateRow.UsdTry)
|
||||
eurV := roundDerivedWithRule(ruleItem.Rule, "EUR", level, tryV/rateRow.EurTry)
|
||||
if usd > 0 {
|
||||
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "USD", LevelNo: level, Price: usd})
|
||||
}
|
||||
if eurV > 0 {
|
||||
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "EUR", LevelNo: level, Price: eurV})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(derivedTargets) == 0 {
|
||||
return 0, skipped, nil
|
||||
}
|
||||
written, err := bulkUpsertSdprcDerived(ctx, pg, derivedTargets)
|
||||
return written, skipped, err
|
||||
}
|
||||
|
||||
func loadLatestSdprcTiers(ctx context.Context, pg *sql.DB, productCode string, currency string) ([6]float64, bool, error) {
|
||||
var out [6]float64
|
||||
productCode = strings.TrimSpace(productCode)
|
||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
||||
if productCode == "" {
|
||||
return out, false, nil
|
||||
}
|
||||
if currency != "USD" && currency != "TRY" {
|
||||
return out, false, nil
|
||||
}
|
||||
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (sdprc.sdprcgrp_id)
|
||||
sdprc.sdprcgrp_id AS grp,
|
||||
COALESCE(sdprc.prc, 0)::float8 AS prc
|
||||
FROM sdprc
|
||||
JOIN mmitem ON mmitem.id = sdprc.mmitem_id
|
||||
WHERE mmitem.code = $1
|
||||
AND sdprc.crn = $2
|
||||
AND sdprc.sdprcgrp_id BETWEEN 1 AND 6
|
||||
AND sdprc.prc IS NOT NULL
|
||||
AND sdprc.prc > 0
|
||||
ORDER BY sdprc.sdprcgrp_id, sdprc.zlins_dttm DESC
|
||||
)
|
||||
SELECT grp, prc FROM latest ORDER BY grp;
|
||||
`, productCode, currency)
|
||||
if err != nil {
|
||||
return out, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
found := 0
|
||||
for rows.Next() {
|
||||
var grp int
|
||||
var prc float64
|
||||
if err := rows.Scan(&grp, &prc); err != nil {
|
||||
return out, false, err
|
||||
}
|
||||
if grp >= 1 && grp <= 6 && prc > 0 {
|
||||
out[grp-1] = prc
|
||||
found++
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return out, false, err
|
||||
}
|
||||
return out, found == 6, nil
|
||||
}
|
||||
|
||||
func bulkUpsertSdprcDerived(ctx context.Context, pg *sql.DB, targets []sdprcPublishRow) (int, error) {
|
||||
raw, err := json.Marshal(targets)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(2003, 1)`); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
q := `
|
||||
WITH input AS (
|
||||
SELECT *
|
||||
FROM jsonb_to_recordset($1::jsonb) AS x(product_code text, currency text, level_no int, price float8)
|
||||
),
|
||||
norm AS (
|
||||
SELECT
|
||||
NULLIF(BTRIM(product_code), '') AS product_code,
|
||||
UPPER(NULLIF(BTRIM(currency), '')) AS currency,
|
||||
COALESCE(level_no, 0) AS level_no,
|
||||
COALESCE(price, 0) AS price
|
||||
FROM input
|
||||
),
|
||||
dims_cache AS (
|
||||
SELECT
|
||||
NULLIF(BTRIM(c.product_code), '') AS product_code,
|
||||
c.dim1,
|
||||
c.dim3
|
||||
FROM mk_mmitem_dim_combo c
|
||||
JOIN norm
|
||||
ON norm.product_code = c.product_code
|
||||
WHERE c.dim1 IS NOT NULL
|
||||
),
|
||||
dims_sdprc AS (
|
||||
SELECT
|
||||
norm.product_code AS product_code,
|
||||
s.dim1 AS dim1,
|
||||
s.dim3 AS dim3
|
||||
FROM norm
|
||||
JOIN mmitem mm
|
||||
ON mm.code = norm.product_code
|
||||
JOIN sdprc s
|
||||
ON s.mmitem_id = mm.id
|
||||
WHERE s.dim1 IS NOT NULL
|
||||
AND s.dim1 > 0
|
||||
GROUP BY norm.product_code, s.dim1, s.dim3
|
||||
),
|
||||
dims AS (
|
||||
SELECT product_code, dim1, dim3 FROM dims_cache
|
||||
UNION
|
||||
SELECT product_code, dim1, dim3 FROM dims_sdprc
|
||||
),
|
||||
mapped AS (
|
||||
SELECT
|
||||
mm.id AS mmitem_id,
|
||||
m.sdprcgrp_id AS sdprcgrp_id,
|
||||
norm.currency AS crn,
|
||||
d.dim1 AS dim1,
|
||||
d.dim3 AS dim3,
|
||||
norm.price AS prc
|
||||
FROM norm
|
||||
JOIN dims d
|
||||
ON d.product_code = norm.product_code
|
||||
JOIN mk_price_target_map_pg m
|
||||
ON m.is_active = TRUE
|
||||
AND m.currency = norm.currency
|
||||
AND m.level_no = norm.level_no
|
||||
JOIN mmitem mm
|
||||
ON mm.code = norm.product_code
|
||||
WHERE norm.product_code IS NOT NULL
|
||||
AND norm.currency IN ('USD','EUR','TRY')
|
||||
AND norm.level_no BETWEEN 1 AND 6
|
||||
AND norm.price > 0
|
||||
AND m.sdprcgrp_id IS NOT NULL
|
||||
),
|
||||
latest AS (
|
||||
SELECT DISTINCT ON (s.mmitem_id, s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0))
|
||||
s.id,
|
||||
s.mmitem_id,
|
||||
s.sdprcgrp_id,
|
||||
s.crn,
|
||||
s.dim1,
|
||||
s.dim3
|
||||
FROM sdprc s
|
||||
JOIN mapped m
|
||||
ON m.mmitem_id = s.mmitem_id
|
||||
AND m.sdprcgrp_id = s.sdprcgrp_id
|
||||
AND m.crn = s.crn
|
||||
AND m.dim1 = s.dim1
|
||||
AND ((m.dim3 IS NULL AND s.dim3 IS NULL) OR (m.dim3 = s.dim3))
|
||||
ORDER BY s.mmitem_id, s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0), s.zlins_dttm DESC
|
||||
),
|
||||
updated AS (
|
||||
UPDATE sdprc s
|
||||
SET prc = m.prc,
|
||||
zlins_dttm = now()
|
||||
FROM latest l
|
||||
JOIN mapped m
|
||||
ON m.mmitem_id=l.mmitem_id AND m.sdprcgrp_id=l.sdprcgrp_id AND m.crn=l.crn
|
||||
AND m.dim1 = l.dim1 AND ((m.dim3 IS NULL AND l.dim3 IS NULL) OR (m.dim3 = l.dim3))
|
||||
WHERE s.id = l.id
|
||||
AND s.prc IS DISTINCT FROM m.prc
|
||||
RETURNING 1
|
||||
),
|
||||
inserted AS (
|
||||
INSERT INTO sdprc (mmitem_id, sdprcgrp_id, crn, dim1, dim3, prc, zlins_dttm)
|
||||
SELECT m.mmitem_id, m.sdprcgrp_id, m.crn, m.dim1, m.dim3, m.prc, now()
|
||||
FROM mapped m
|
||||
LEFT JOIN latest l
|
||||
ON l.mmitem_id=m.mmitem_id AND l.sdprcgrp_id=m.sdprcgrp_id AND l.crn=m.crn
|
||||
AND l.dim1 = m.dim1 AND ((l.dim3 IS NULL AND m.dim3 IS NULL) OR (l.dim3 = m.dim3))
|
||||
WHERE l.id IS NULL
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT (SELECT COUNT(*) FROM updated)::int + (SELECT COUNT(*) FROM inserted)::int;
|
||||
`
|
||||
var written int
|
||||
if err := tx.QueryRowContext(ctx, q, raw).Scan(&written); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
174
svc/queries/product_pricing_recalc_queue.go
Normal file
174
svc/queries/product_pricing_recalc_queue.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// EnqueuePriceRecalc enqueues product codes for delta FX publish.
|
||||
// It is safe to call repeatedly; duplicates in pending/processing are ignored.
|
||||
func EnqueuePriceRecalc(ctx context.Context, tx *sql.Tx, productCodes []string, reason string) (int, error) {
|
||||
if len(productCodes) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "manual"
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
inserted := 0
|
||||
for _, raw := range productCodes {
|
||||
code := strings.TrimSpace(raw)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[code]; ok {
|
||||
continue
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_price_recalc_queue (
|
||||
product_code, pricing_parameter_id, reason, status, attempts,
|
||||
available_at, queued_at, processed_at, last_error,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES ($1, NULL, $2, 'pending', 0, now(), now(), NULL, '', now(), now())
|
||||
`, code, reason)
|
||||
if err != nil {
|
||||
if pe, ok := err.(*pq.Error); ok && pe != nil && string(pe.Code) == "23505" {
|
||||
// Duplicate in pending/processing (partial unique index).
|
||||
continue
|
||||
}
|
||||
return inserted, err
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
type PriceRecalcQueueItem struct {
|
||||
ID int64
|
||||
ProductCode string
|
||||
Attempts int
|
||||
}
|
||||
|
||||
// ClaimPriceRecalcQueue claims up to limit pending items for processing (SKIP LOCKED).
|
||||
func ClaimPriceRecalcQueue(ctx context.Context, tx *sql.Tx, limit int) ([]PriceRecalcQueueItem, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
WITH picked AS (
|
||||
SELECT id
|
||||
FROM mk_price_recalc_queue
|
||||
WHERE status = 'pending'
|
||||
AND available_at <= now()
|
||||
ORDER BY queued_at
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE mk_price_recalc_queue q
|
||||
SET status = 'processing', updated_at = now()
|
||||
FROM picked
|
||||
WHERE q.id = picked.id
|
||||
RETURNING q.id, q.product_code, q.attempts;
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]PriceRecalcQueueItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var it PriceRecalcQueueItem
|
||||
if err := rows.Scan(&it.ID, &it.ProductCode, &it.Attempts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.ProductCode = strings.TrimSpace(it.ProductCode)
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func MarkPriceRecalcQueueDone(ctx context.Context, tx *sql.Tx, id int64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_price_recalc_queue
|
||||
SET status='done',
|
||||
processed_at = now(),
|
||||
updated_at = now(),
|
||||
last_error=''
|
||||
WHERE id=$1;
|
||||
`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func MarkPriceRecalcQueueFailed(ctx context.Context, tx *sql.Tx, id int64, attempts int, errText string) error {
|
||||
errText = strings.TrimSpace(errText)
|
||||
if len(errText) > 900 {
|
||||
errText = errText[:900]
|
||||
}
|
||||
// Exponential-ish backoff: 5m, 15m, 60m.
|
||||
delay := 5 * time.Minute
|
||||
if attempts >= 1 {
|
||||
delay = 15 * time.Minute
|
||||
}
|
||||
if attempts >= 2 {
|
||||
delay = 60 * time.Minute
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_price_recalc_queue
|
||||
SET status='failed',
|
||||
attempts = attempts + 1,
|
||||
processed_at = now(),
|
||||
updated_at = now(),
|
||||
last_error=$2,
|
||||
available_at = now() + $3::interval
|
||||
WHERE id=$1;
|
||||
`, id, errText, fmt.Sprintf("%d seconds", int(delay.Seconds())))
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkPriceRecalcQueueDoneByProductCodes marks pending/processing rows as done for given product codes.
|
||||
// This is useful when an immediate publish path completes successfully and we want to avoid a second run.
|
||||
func MarkPriceRecalcQueueDoneByProductCodes(ctx context.Context, tx *sql.Tx, productCodes []string) (int64, error) {
|
||||
if len(productCodes) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
clean := make([]string, 0, len(productCodes))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range productCodes {
|
||||
code := strings.TrimSpace(raw)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[code]; ok {
|
||||
continue
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
clean = append(clean, code)
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_price_recalc_queue
|
||||
SET status='done',
|
||||
processed_at = now(),
|
||||
updated_at = now(),
|
||||
last_error=''
|
||||
WHERE product_code = ANY($1)
|
||||
AND status IN ('pending','processing');
|
||||
`, pq.Array(clean))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ra, _ := res.RowsAffected()
|
||||
return ra, nil
|
||||
}
|
||||
97
svc/routes/brand_group_currency.go
Normal file
97
svc/routes/brand_group_currency.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bssapp-backend/queries"
|
||||
"bssapp-backend/utils"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BrandGroupCurrencyItem struct {
|
||||
ID int `json:"id"`
|
||||
AnchorMode string `json:"anchor_mode"`
|
||||
}
|
||||
|
||||
type BrandGroupCurrencyPayload struct {
|
||||
Items []BrandGroupCurrencyItem `json:"items"`
|
||||
}
|
||||
|
||||
func GetBrandGroupCurrencyHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err := queries.EnsureBrandClassificationTables(pg); err != nil {
|
||||
http.Error(w, "brand tables bootstrap error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx := utils.ContextWithTraceID(r.Context(), traceID)
|
||||
|
||||
rows, err := queries.ListBrandGroups(ctx, pg)
|
||||
if err != nil {
|
||||
http.Error(w, "brand group currency list error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(rows)
|
||||
}
|
||||
}
|
||||
|
||||
func SaveBrandGroupCurrencyHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err := queries.EnsureBrandClassificationTables(pg); err != nil {
|
||||
http.Error(w, "brand tables bootstrap error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var payload BrandGroupCurrencyPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(payload.Items) == 0 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "updated": 0})
|
||||
return
|
||||
}
|
||||
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx := utils.ContextWithTraceID(r.Context(), traceID)
|
||||
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "pg transaction start error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
updated := 0
|
||||
for _, item := range payload.Items {
|
||||
if item.ID <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mode := strings.ToUpper(strings.TrimSpace(item.AnchorMode))
|
||||
if mode != "TRY" && mode != "USD" {
|
||||
http.Error(w, "invalid anchor_mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := queries.SetBrandGroupAnchorMode(ctx, tx, item.ID, mode); err != nil {
|
||||
http.Error(w, "brand group currency save error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := queries.SyncPricingRuleAnchorModesByGroup(ctx, tx, item.ID, mode); err != nil {
|
||||
http.Error(w, "pricing rule anchor sync error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, "pg transaction commit error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "updated": updated})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/queries"
|
||||
"bssapp-backend/utils"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/lib/pq"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -33,6 +35,11 @@ type PricingRuleImportItem struct {
|
||||
Marka string `json:"marka"`
|
||||
BrandCode string `json:"brand_code"`
|
||||
BrandGroupSec string `json:"brand_group"`
|
||||
StrategyCode string `json:"strategy_code"`
|
||||
AnchorMode string `json:"anchor_mode"`
|
||||
CalcEnabled bool `json:"calc_enabled"`
|
||||
PublishPostgres bool `json:"publish_postgres"`
|
||||
PublishNebim bool `json:"publish_nebim"`
|
||||
IsActive bool `json:"is_active"`
|
||||
TryBase float64 `json:"try_base"`
|
||||
Try1 float64 `json:"try1"`
|
||||
@@ -43,6 +50,7 @@ type PricingRuleImportItem struct {
|
||||
Try6 float64 `json:"try6"`
|
||||
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
||||
TryRetailStep float64 `json:"try_retail_step"`
|
||||
TryRetailMode string `json:"try_retail_mode"`
|
||||
UsdBase float64 `json:"usd_base"`
|
||||
Usd1 float64 `json:"usd1"`
|
||||
Usd2 float64 `json:"usd2"`
|
||||
@@ -52,6 +60,7 @@ type PricingRuleImportItem struct {
|
||||
Usd6 float64 `json:"usd6"`
|
||||
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
||||
UsdRetailStep float64 `json:"usd_retail_step"`
|
||||
UsdRetailMode string `json:"usd_retail_mode"`
|
||||
EurBase float64 `json:"eur_base"`
|
||||
Eur1 float64 `json:"eur1"`
|
||||
Eur2 float64 `json:"eur2"`
|
||||
@@ -61,6 +70,7 @@ type PricingRuleImportItem struct {
|
||||
Eur6 float64 `json:"eur6"`
|
||||
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
||||
EurRetailStep float64 `json:"eur_retail_step"`
|
||||
EurRetailMode string `json:"eur_retail_mode"`
|
||||
}
|
||||
|
||||
type PricingRuleImportPayload struct {
|
||||
@@ -77,6 +87,52 @@ type PricingRuleImportResult struct {
|
||||
ErrorCount int `json:"error_count"`
|
||||
}
|
||||
|
||||
func normalizePricingStrategyCode(v string) string {
|
||||
v = strings.ToUpper(strings.TrimSpace(v))
|
||||
if v == "" {
|
||||
return "CORE"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func normalizePricingAnchorMode(v string) string {
|
||||
v = strings.ToUpper(strings.TrimSpace(v))
|
||||
if v == "" {
|
||||
return "USD"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func isValidPricingStrategyCode(v string) bool {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return true
|
||||
}
|
||||
switch normalizePricingStrategyCode(v) {
|
||||
case "CORE", "PREMIUM", "SARTORIAL":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isValidPricingAnchorMode(v string) bool {
|
||||
switch normalizePricingAnchorMode(v) {
|
||||
case "TRY", "USD":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isValidPricingRetailMode(v string) bool {
|
||||
switch queries.NormalizeRetailModeForRoute(v) {
|
||||
case "STEP", "END_99", "END_49", "BAND_99", "BAND_49":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func GetPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -104,37 +160,130 @@ func SavePricingRulesBulkHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
ctx := utils.ContextWithTraceID(r.Context(), traceID)
|
||||
logger := utils.SlogFromContext(ctx).With("handler", "pricing-rules.bulk-save")
|
||||
|
||||
claims, _ := auth.GetClaimsFromContext(ctx)
|
||||
if claims != nil {
|
||||
logger = logger.With("user", claims.Username, "user_id", claims.ID)
|
||||
}
|
||||
existingIDCount := 0
|
||||
newIDCount := 0
|
||||
for _, it := range payload.Items {
|
||||
if strings.TrimSpace(it.ID) != "" {
|
||||
existingIDCount++
|
||||
} else {
|
||||
newIDCount++
|
||||
}
|
||||
}
|
||||
logger.Info("bulk-save:start",
|
||||
"items", len(payload.Items),
|
||||
"existing_id", existingIDCount,
|
||||
"new_id", newIDCount,
|
||||
)
|
||||
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
logger.Error("bulk-save:tx-begin:error", "err", err)
|
||||
http.Error(w, "pg transaction start error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Serialize writes touching mk_urunpricingprmtr/mk_pricing_rule/mk_pricex/mk_priceroll
|
||||
// to avoid deadlocks with pricing-parameter sync and concurrent bulk-saves.
|
||||
lockWaitStarted := time.Now()
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil {
|
||||
logger.Error("bulk-save:advisory-lock:error", "err", err)
|
||||
http.Error(w, "pg advisory lock error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
logger.Info("bulk-save:advisory-lock:acquired", "wait_ms", time.Since(lockWaitStarted).Milliseconds())
|
||||
|
||||
logPgErr := func(msg string, err error, it queries.PricingRuleSaveItem) {
|
||||
fields := []any{
|
||||
"pricing_parameter_id", it.PricingParameterID,
|
||||
"id", strings.TrimSpace(it.ID),
|
||||
"err", err,
|
||||
}
|
||||
if pe, ok := err.(*pq.Error); ok && pe != nil {
|
||||
fields = append(fields,
|
||||
"sqlstate", string(pe.Code),
|
||||
"constraint", pe.Constraint,
|
||||
"table", pe.Table,
|
||||
"column", pe.Column,
|
||||
"detail", pe.Detail,
|
||||
"where", pe.Where,
|
||||
)
|
||||
}
|
||||
logger.Error(msg, fields...)
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for _, it := range payload.Items {
|
||||
// Zero means that no rounding rule has been configured yet.
|
||||
if it.TryWholesaleStep < 0 || it.TryRetailStep < 0 || it.UsdWholesaleStep < 0 || it.UsdRetailStep < 0 || it.EurWholesaleStep < 0 || it.EurRetailStep < 0 {
|
||||
logger.Warn("bulk-save:invalid-rounding-step",
|
||||
"pricing_parameter_id", it.PricingParameterID,
|
||||
"id", strings.TrimSpace(it.ID),
|
||||
)
|
||||
http.Error(w, "invalid rounding step", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, err := queries.UpsertPricingRule(ctx, tx, it)
|
||||
if err != nil {
|
||||
http.Error(w, "pricing rule save error", http.StatusInternalServerError)
|
||||
if !isValidPricingStrategyCode(it.StrategyCode) {
|
||||
logger.Warn("bulk-save:invalid-strategy-code",
|
||||
"pricing_parameter_id", it.PricingParameterID,
|
||||
"id", strings.TrimSpace(it.ID),
|
||||
"strategy_code", it.StrategyCode,
|
||||
)
|
||||
http.Error(w, "invalid strategy_code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if id != "" {
|
||||
updated++
|
||||
if !isValidPricingAnchorMode(it.AnchorMode) {
|
||||
logger.Warn("bulk-save:invalid-anchor-mode",
|
||||
"pricing_parameter_id", it.PricingParameterID,
|
||||
"id", strings.TrimSpace(it.ID),
|
||||
"anchor_mode", it.AnchorMode,
|
||||
)
|
||||
http.Error(w, "invalid anchor_mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isValidPricingRetailMode(it.TryRetailMode) || !isValidPricingRetailMode(it.UsdRetailMode) || !isValidPricingRetailMode(it.EurRetailMode) {
|
||||
logger.Warn("bulk-save:invalid-retail-mode",
|
||||
"pricing_parameter_id", it.PricingParameterID,
|
||||
"id", strings.TrimSpace(it.ID),
|
||||
"try_retail_mode", it.TryRetailMode,
|
||||
"usd_retail_mode", it.UsdRetailMode,
|
||||
"eur_retail_mode", it.EurRetailMode,
|
||||
)
|
||||
http.Error(w, "invalid retail_mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dbStarted := time.Now()
|
||||
updated, err = queries.BulkSavePricingRulesFast(ctx, tx, payload.Items)
|
||||
if err != nil {
|
||||
// best-effort: log first item context
|
||||
if len(payload.Items) > 0 {
|
||||
logPgErr("bulk-save:bulk-fast:error", err, payload.Items[0])
|
||||
} else {
|
||||
logger.Error("bulk-save:bulk-fast:error", "err", err)
|
||||
}
|
||||
http.Error(w, "pricing rule save error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
logger.Info("bulk-save:db:done", "updated", updated, "duration_ms", time.Since(dbStarted).Milliseconds())
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
logger.Error("bulk-save:commit:error", "err", err)
|
||||
http.Error(w, "pg transaction commit error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
logger.Info("bulk-save:done", "updated", updated, "duration_ms", time.Since(started).Milliseconds())
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "updated": updated})
|
||||
}
|
||||
}
|
||||
@@ -163,6 +312,12 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Same global lock as bulk-save: prevents deadlocks with concurrent updates/sync.
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil {
|
||||
http.Error(w, "pg advisory lock error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
updated := 0
|
||||
matched := 0
|
||||
skipped := 0
|
||||
@@ -171,6 +326,18 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
||||
http.Error(w, "invalid rounding step", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isValidPricingStrategyCode(raw.StrategyCode) {
|
||||
http.Error(w, "invalid strategy_code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isValidPricingAnchorMode(raw.AnchorMode) {
|
||||
http.Error(w, "invalid anchor_mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isValidPricingRetailMode(raw.TryRetailMode) || !isValidPricingRetailMode(raw.UsdRetailMode) || !isValidPricingRetailMode(raw.EurRetailMode) {
|
||||
http.Error(w, "invalid retail_mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pricingParameterID, err := queries.FindActivePricingParameterByScope(ctx, tx, queries.PricingParameterRowForImport(
|
||||
raw.AskiliYan,
|
||||
@@ -195,6 +362,11 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
||||
|
||||
_, err = queries.UpsertPricingRule(ctx, tx, queries.PricingRuleSaveItem{
|
||||
PricingParameterID: pricingParameterID,
|
||||
StrategyCode: normalizePricingStrategyCode(raw.StrategyCode),
|
||||
AnchorMode: normalizePricingAnchorMode(raw.AnchorMode),
|
||||
CalcEnabled: raw.CalcEnabled,
|
||||
PublishPostgres: raw.PublishPostgres,
|
||||
PublishNebim: raw.PublishNebim,
|
||||
IsActive: raw.IsActive,
|
||||
TryBase: raw.TryBase,
|
||||
Try1: raw.Try1,
|
||||
@@ -205,6 +377,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
||||
Try6: raw.Try6,
|
||||
TryWholesaleStep: raw.TryWholesaleStep,
|
||||
TryRetailStep: raw.TryRetailStep,
|
||||
TryRetailMode: queries.NormalizeRetailModeForRoute(raw.TryRetailMode),
|
||||
UsdBase: raw.UsdBase,
|
||||
Usd1: raw.Usd1,
|
||||
Usd2: raw.Usd2,
|
||||
@@ -214,6 +387,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
||||
Usd6: raw.Usd6,
|
||||
UsdWholesaleStep: raw.UsdWholesaleStep,
|
||||
UsdRetailStep: raw.UsdRetailStep,
|
||||
UsdRetailMode: queries.NormalizeRetailModeForRoute(raw.UsdRetailMode),
|
||||
EurBase: raw.EurBase,
|
||||
Eur1: raw.Eur1,
|
||||
Eur2: raw.Eur2,
|
||||
@@ -223,6 +397,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
||||
Eur6: raw.Eur6,
|
||||
EurWholesaleStep: raw.EurWholesaleStep,
|
||||
EurRetailStep: raw.EurRetailStep,
|
||||
EurRetailMode: queries.NormalizeRetailModeForRoute(raw.EurRetailMode),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "pricing rule import error", http.StatusInternalServerError)
|
||||
@@ -470,7 +645,34 @@ func sortPricingRuleExportRows(rows []queries.PricingParameterRuleRow, sortBy st
|
||||
return boolRank(liActive) > boolRank(ljActive)
|
||||
}
|
||||
return boolRank(liActive) < boolRank(ljActive)
|
||||
case "askili_yan", "kategori", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "icerik", "marka", "brand_code", "brand_group":
|
||||
case "calc_enabled", "publish_postgres", "publish_nebim":
|
||||
liValue, ljValue := false, false
|
||||
if li.Rule != nil {
|
||||
switch sortBy {
|
||||
case "calc_enabled":
|
||||
liValue = li.Rule.CalcEnabled
|
||||
case "publish_postgres":
|
||||
liValue = li.Rule.PublishPostgres
|
||||
case "publish_nebim":
|
||||
liValue = li.Rule.PublishNebim
|
||||
}
|
||||
}
|
||||
if lj.Rule != nil {
|
||||
switch sortBy {
|
||||
case "calc_enabled":
|
||||
ljValue = lj.Rule.CalcEnabled
|
||||
case "publish_postgres":
|
||||
ljValue = lj.Rule.PublishPostgres
|
||||
case "publish_nebim":
|
||||
ljValue = lj.Rule.PublishNebim
|
||||
}
|
||||
}
|
||||
if desc {
|
||||
return boolRank(liValue) > boolRank(ljValue)
|
||||
}
|
||||
return boolRank(liValue) < boolRank(ljValue)
|
||||
case "askili_yan", "kategori", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "icerik", "marka", "brand_code", "brand_group", "anchor_mode",
|
||||
"try_retail_mode", "usd_retail_mode", "eur_retail_mode":
|
||||
vi := pricingRuleStringValue(li, sortBy)
|
||||
vj := pricingRuleStringValue(lj, sortBy)
|
||||
if desc {
|
||||
@@ -515,6 +717,26 @@ func pricingRuleStringValue(row queries.PricingParameterRuleRow, field string) s
|
||||
return row.BrandCode
|
||||
case "brand_group":
|
||||
return row.BrandGroupSec
|
||||
case "anchor_mode":
|
||||
if row.Rule == nil {
|
||||
return "USD"
|
||||
}
|
||||
return row.Rule.AnchorMode
|
||||
case "try_retail_mode":
|
||||
if row.Rule == nil {
|
||||
return "STEP"
|
||||
}
|
||||
return queries.NormalizeRetailModeForRoute(row.Rule.TryRetailMode)
|
||||
case "usd_retail_mode":
|
||||
if row.Rule == nil {
|
||||
return "STEP"
|
||||
}
|
||||
return queries.NormalizeRetailModeForRoute(row.Rule.UsdRetailMode)
|
||||
case "eur_retail_mode":
|
||||
if row.Rule == nil {
|
||||
return "STEP"
|
||||
}
|
||||
return queries.NormalizeRetailModeForRoute(row.Rule.EurRetailMode)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -523,10 +745,10 @@ func pricingRuleStringValue(row queries.PricingParameterRuleRow, field string) s
|
||||
func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
|
||||
headers := []string{
|
||||
"DURUM", "AKTIF", "ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU",
|
||||
"ICERIK", "MARKA", "BRAND CODE", "MARKA GRUBU",
|
||||
"TRY TOPTAN YUVARLAMA", "TRY PERAKENDE YUVARLAMA", "TRY TABAN", "TRY 1", "TRY 2", "TRY 3", "TRY 4", "TRY 5", "TRY 6",
|
||||
"USD TOPTAN YUVARLAMA", "USD PERAKENDE YUVARLAMA", "USD TABAN", "USD 1", "USD 2", "USD 3", "USD 4", "USD 5", "USD 6",
|
||||
"EUR TOPTAN YUVARLAMA", "EUR PERAKENDE YUVARLAMA", "EUR TABAN", "EUR 1", "EUR 2", "EUR 3", "EUR 4", "EUR 5", "EUR 6",
|
||||
"ICERIK", "MARKA", "BRAND CODE", "MARKA GRUBU", "ANCHOR MODE", "HESAP AKTIF", "PG YAYIN", "NEBIM YAYIN",
|
||||
"TRY TOPTAN YUVARLAMA", "TRY PERAKENDE MODU", "TRY PERAKENDE DEGERI", "TRY TABAN", "TRY 1", "TRY 2", "TRY 3", "TRY 4", "TRY 5", "TRY 6",
|
||||
"USD TOPTAN YUVARLAMA", "USD PERAKENDE MODU", "USD PERAKENDE DEGERI", "USD TABAN", "USD 1", "USD 2", "USD 3", "USD 4", "USD 5", "USD 6",
|
||||
"EUR TOPTAN YUVARLAMA", "EUR PERAKENDE MODU", "EUR PERAKENDE DEGERI", "EUR TABAN", "EUR 1", "EUR 2", "EUR 3", "EUR 4", "EUR 5", "EUR 6",
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, h := range headers {
|
||||
@@ -551,10 +773,15 @@ func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
|
||||
row.UrunAnaGrubu,
|
||||
row.UrunAltGrubu,
|
||||
row.Icerik,
|
||||
row.Marka,
|
||||
csvExcelTextValue(row.Marka),
|
||||
csvExcelTextValue(row.BrandCode),
|
||||
row.BrandGroupSec,
|
||||
pricingRuleStringValue(row, "anchor_mode"),
|
||||
map[bool]string{true: "Aktif", false: "Pasif"}[row.Rule == nil || row.Rule.CalcEnabled],
|
||||
map[bool]string{true: "Evet", false: "Hayir"}[row.Rule == nil || row.Rule.PublishPostgres],
|
||||
map[bool]string{true: "Evet", false: "Hayir"}[row.Rule == nil || row.Rule.PublishNebim],
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_wholesale_step")),
|
||||
pricingRuleStringValue(row, "try_retail_mode"),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_retail_step")),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_base")),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try1")),
|
||||
@@ -564,6 +791,7 @@ func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try5")),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try6")),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_wholesale_step")),
|
||||
pricingRuleStringValue(row, "usd_retail_mode"),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_retail_step")),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_base")),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd1")),
|
||||
@@ -573,6 +801,7 @@ func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd5")),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd6")),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_wholesale_step")),
|
||||
pricingRuleStringValue(row, "eur_retail_mode"),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_retail_step")),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_base")),
|
||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur1")),
|
||||
|
||||
107
svc/routes/product_pricing_calc.go
Normal file
107
svc/routes/product_pricing_calc.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/queries"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type productPricingCalcRequest struct {
|
||||
ProductCodes []string `json:"product_codes"`
|
||||
RateDate string `json:"rate_date"`
|
||||
ForceFxRefresh bool `json:"force_fx_refresh"`
|
||||
PreviewOnly bool `json:"preview_only"`
|
||||
|
||||
Search string `json:"q"`
|
||||
ProductCode []string `json:"product_code"`
|
||||
BrandGroup []string `json:"brand_group_selection"`
|
||||
AskiliYan []string `json:"askili_yan"`
|
||||
Kategori []string `json:"kategori"`
|
||||
UrunIlkGrubu []string `json:"urun_ilk_grubu"`
|
||||
UrunAnaGrubu []string `json:"urun_ana_grubu"`
|
||||
UrunAltGrubu []string `json:"urun_alt_grubu"`
|
||||
Icerik []string `json:"icerik"`
|
||||
Karisim []string `json:"karisim"`
|
||||
Marka []string `json:"marka"`
|
||||
}
|
||||
|
||||
func PostProductPricingCalculateSnapshotsHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
started := time.Now()
|
||||
traceID := buildPricingTraceID(r)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 170*time.Second)
|
||||
defer cancel()
|
||||
|
||||
reqBody := productPricingCalcRequest{}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
}
|
||||
|
||||
filters := queries.ProductPricingFilters{
|
||||
Search: strings.TrimSpace(reqBody.Search),
|
||||
ProductCode: reqBody.ProductCode,
|
||||
BrandGroup: reqBody.BrandGroup,
|
||||
AskiliYan: reqBody.AskiliYan,
|
||||
Kategori: reqBody.Kategori,
|
||||
UrunIlkGrubu: reqBody.UrunIlkGrubu,
|
||||
UrunAnaGrubu: reqBody.UrunAnaGrubu,
|
||||
UrunAltGrubu: reqBody.UrunAltGrubu,
|
||||
Icerik: reqBody.Icerik,
|
||||
Karisim: reqBody.Karisim,
|
||||
Marka: reqBody.Marka,
|
||||
}
|
||||
if filters.Search == "" && len(filters.ProductCode) == 0 && len(filters.BrandGroup) == 0 &&
|
||||
len(filters.AskiliYan) == 0 && len(filters.Kategori) == 0 && len(filters.UrunIlkGrubu) == 0 &&
|
||||
len(filters.UrunAnaGrubu) == 0 && len(filters.UrunAltGrubu) == 0 && len(filters.Icerik) == 0 &&
|
||||
len(filters.Karisim) == 0 && len(filters.Marka) == 0 {
|
||||
filters = parseProductPricingFilters(r)
|
||||
}
|
||||
|
||||
calcReq := queries.ProductPricingSnapshotCalcRequest{
|
||||
ProductCodes: reqBody.ProductCodes,
|
||||
Filters: filters,
|
||||
RateDate: reqBody.RateDate,
|
||||
ForceFxRefresh: reqBody.ForceFxRefresh,
|
||||
}
|
||||
if reqBody.PreviewOnly {
|
||||
result, err := queries.PreviewProductPricingSnapshots(ctx, pg, calcReq)
|
||||
if err != nil {
|
||||
log.Printf("[ProductPricingCalcPreview] trace=%s user=%s id=%d err=%v duration_ms=%d",
|
||||
traceID, claims.Username, claims.ID, err, time.Since(started).Milliseconds())
|
||||
http.Error(w, "Urun fiyat hesap onizlemesi olusturulamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("[ProductPricingCalcPreview] trace=%s user=%s id=%d requested=%d calculated=%d skipped=%d fx_date=%s duration_ms=%d",
|
||||
traceID, claims.Username, claims.ID, result.Requested, result.Calculated, result.Skipped, result.RateDate, time.Since(started).Milliseconds())
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
return
|
||||
}
|
||||
result, err := queries.CalculateProductPricingSnapshots(ctx, pg, calcReq)
|
||||
if err != nil {
|
||||
log.Printf("[ProductPricingCalc] trace=%s user=%s id=%d err=%v duration_ms=%d",
|
||||
traceID, claims.Username, claims.ID, err, time.Since(started).Milliseconds())
|
||||
http.Error(w, "Urun fiyat hesaplari olusturulamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[ProductPricingCalc] trace=%s user=%s id=%d requested=%d calculated=%d skipped=%d fx_date=%s duration_ms=%d",
|
||||
traceID, claims.Username, claims.ID, result.Requested, result.Calculated, result.Skipped, result.RateDate, time.Since(started).Milliseconds())
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
}
|
||||
265
svc/routes/product_pricing_change_mail.go
Normal file
265
svc/routes/product_pricing_change_mail.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/internal/mailer"
|
||||
"bssapp-backend/models"
|
||||
"bssapp-backend/queries"
|
||||
)
|
||||
|
||||
func loadPricingRecipients(pg *sql.DB, firstGroupCode string) ([]string, error) {
|
||||
rows, err := pg.Query(`
|
||||
SELECT DISTINCT TRIM(m.email) AS email
|
||||
FROM mk_pricing_first_group_mail f
|
||||
JOIN mk_mail m
|
||||
ON m.id = f.mail_id
|
||||
WHERE m.is_active = true
|
||||
AND COALESCE(TRIM(m.email), '') <> ''
|
||||
AND UPPER(TRIM(f.urun_ilk_grubu)) = UPPER(TRIM($1))
|
||||
ORDER BY email
|
||||
`, strings.TrimSpace(firstGroupCode))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]string, 0, 16)
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email = strings.TrimSpace(email)
|
||||
if email != "" {
|
||||
out = append(out, email)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func htmlEscapeMini(s string) string {
|
||||
// Minimal safe escaping for our templated cells.
|
||||
r := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func fmtMoneyMail(v float64) string { return fmt.Sprintf("%.2f", v) }
|
||||
func fmtQtyMail(v float64) string { return fmt.Sprintf("%.2f", v) }
|
||||
|
||||
func fmtDateTRFromISO(d string) string {
|
||||
d = strings.TrimSpace(d)
|
||||
if len(d) >= 10 {
|
||||
d = d[:10]
|
||||
}
|
||||
parts := strings.Split(d, "-")
|
||||
if len(parts) != 3 {
|
||||
if d == "" {
|
||||
return "-"
|
||||
}
|
||||
return d
|
||||
}
|
||||
y, m, day := parts[0], parts[1], parts[2]
|
||||
if y == "" || m == "" || day == "" {
|
||||
return d
|
||||
}
|
||||
return day + "." + m + "." + y
|
||||
}
|
||||
|
||||
func buildPricingChangeMailHTML(firstGroupCode string, rows []models.ProductPricing, actor string, at time.Time) string {
|
||||
// Keep it simple: wide, scrollable table.
|
||||
var b strings.Builder
|
||||
// NOTE: Mail clients often render small fonts; keep this comfortably readable.
|
||||
// Use large inline sizes (some clients still downscale); keep everything inline for maximum compatibility.
|
||||
b.WriteString(`<div style="font-family:Segoe UI, Arial, sans-serif; font-size:18px; line-height:1.35; -webkit-text-size-adjust:100%;">`)
|
||||
b.WriteString(`<div style="margin-bottom:10px;">`)
|
||||
b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Fiyat Degisikligi</b></div>`)
|
||||
b.WriteString(`<div>Urun Ilk Grubu: <b>` + htmlEscapeMini(firstGroupCode) + `</b></div>`)
|
||||
if strings.TrimSpace(actor) != "" {
|
||||
b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`)
|
||||
}
|
||||
b.WriteString(`<div>Tarih: <b>` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `</b></div>`)
|
||||
b.WriteString(`<div>Urun Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`)
|
||||
b.WriteString(`</div>`)
|
||||
|
||||
b.WriteString(`<div style="max-width:100%; overflow-x:auto;">`)
|
||||
b.WriteString(`<table style="border-collapse:collapse; font-size:16px; white-space:nowrap;">`)
|
||||
b.WriteString(`<thead><tr>`)
|
||||
|
||||
heads := []string{
|
||||
"MARKA GRUBU", "MARKA", "BRAND CODE", "URUN KODU",
|
||||
"STOK ADET", "STOK GIRIS", "SON MALIYET", "SON FIYAT",
|
||||
"ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "KARISIM",
|
||||
"MALIYET FIYATI", "TABAN USD", "TABAN TRY",
|
||||
"USD1", "USD2", "USD3", "USD4", "USD5", "USD6",
|
||||
"EUR1", "EUR2", "EUR3", "EUR4", "EUR5", "EUR6",
|
||||
"TRY1", "TRY2", "TRY3", "TRY4", "TRY5", "TRY6",
|
||||
}
|
||||
for _, h := range heads {
|
||||
b.WriteString(`<th style="border:1px solid #d0d0d0; background:#f3f3f3; padding:8px 10px; text-align:left; font-size:16px;">` + htmlEscapeMini(h) + `</th>`)
|
||||
}
|
||||
b.WriteString(`</tr></thead><tbody>`)
|
||||
|
||||
for _, r := range rows {
|
||||
b.WriteString(`<tr>`)
|
||||
cells := []string{
|
||||
r.BrandGroupSec,
|
||||
r.Marka,
|
||||
r.BrandCode,
|
||||
r.ProductCode,
|
||||
fmtQtyMail(r.StockQty),
|
||||
fmtDateTRFromISO(r.StockEntryDate),
|
||||
fmtDateTRFromISO(r.LastCostingDate),
|
||||
fmtDateTRFromISO(r.LastPricingDate),
|
||||
r.AskiliYan,
|
||||
r.Kategori,
|
||||
r.UrunIlkGrubu,
|
||||
r.UrunAnaGrubu,
|
||||
r.UrunAltGrubu,
|
||||
r.Icerik,
|
||||
r.Karisim,
|
||||
fmtMoneyMail(r.CostPrice),
|
||||
fmtMoneyMail(r.BasePriceUsd),
|
||||
fmtMoneyMail(r.BasePriceTry),
|
||||
fmtMoneyMail(r.USD1), fmtMoneyMail(r.USD2), fmtMoneyMail(r.USD3), fmtMoneyMail(r.USD4), fmtMoneyMail(r.USD5), fmtMoneyMail(r.USD6),
|
||||
fmtMoneyMail(r.EUR1), fmtMoneyMail(r.EUR2), fmtMoneyMail(r.EUR3), fmtMoneyMail(r.EUR4), fmtMoneyMail(r.EUR5), fmtMoneyMail(r.EUR6),
|
||||
fmtMoneyMail(r.TRY1), fmtMoneyMail(r.TRY2), fmtMoneyMail(r.TRY3), fmtMoneyMail(r.TRY4), fmtMoneyMail(r.TRY5), fmtMoneyMail(r.TRY6),
|
||||
}
|
||||
for i, c := range cells {
|
||||
align := "left"
|
||||
// right align numeric-ish cells
|
||||
if i >= 4 {
|
||||
switch i {
|
||||
case 4, 15, 16, 17,
|
||||
18, 19, 20, 21, 22, 23,
|
||||
24, 25, 26, 27, 28, 29,
|
||||
30, 31, 32, 33, 34, 35:
|
||||
align = "right"
|
||||
}
|
||||
}
|
||||
b.WriteString(`<td style="border:1px solid #e0e0e0; padding:8px 10px; text-align:` + align + `;">` + htmlEscapeMini(strings.TrimSpace(c)) + `</td>`)
|
||||
}
|
||||
b.WriteString(`</tr>`)
|
||||
}
|
||||
|
||||
b.WriteString(`</tbody></table></div>`)
|
||||
b.WriteString(`<div style="margin-top:12px; font-size:14px; color:#666;">Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.</div>`)
|
||||
b.WriteString(`</div>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sendPricingChangeMails sends one mail per UrunIlkGrubu (group) based on mk_pricing_first_group_mail mapping.
|
||||
// It is designed to be called post-commit in a goroutine.
|
||||
func sendPricingChangeMails(bg context.Context, ml *mailer.GraphMailer, productCodes []string, actor string) {
|
||||
if ml == nil {
|
||||
return
|
||||
}
|
||||
pg := db.PgDB
|
||||
if pg == nil {
|
||||
log.Printf("[pricing-mail] skipped: pg not ready")
|
||||
return
|
||||
}
|
||||
// Ensure mapping tables exist.
|
||||
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
|
||||
log.Printf("[pricing-mail] mapping bootstrap error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(bg, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
codes := make([]string, 0, len(productCodes))
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range productCodes {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[c]; ok {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
codes = append(codes, c)
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := queries.GetAllProductPricingRows(ctx, 500, queries.ProductPricingFilters{ProductCode: codes}, "productCode", false)
|
||||
if err != nil {
|
||||
log.Printf("[pricing-mail] pricing rows query error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
byGroup := map[string][]models.ProductPricing{}
|
||||
for _, r := range rows {
|
||||
g := strings.TrimSpace(r.UrunIlkGrubu)
|
||||
if g == "" {
|
||||
g = "UNKNOWN"
|
||||
}
|
||||
byGroup[g] = append(byGroup[g], r)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for group, list := range byGroup {
|
||||
// No mapping = skip.
|
||||
recipients, err := loadPricingRecipients(pg, group)
|
||||
if err != nil {
|
||||
log.Printf("[pricing-mail] recipient query error group=%s err=%v", group, err)
|
||||
continue
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
log.Printf("[pricing-mail] no recipients mapped group=%s", group)
|
||||
continue
|
||||
}
|
||||
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return strings.TrimSpace(list[i].ProductCode) < strings.TrimSpace(list[j].ProductCode)
|
||||
})
|
||||
|
||||
subject := fmt.Sprintf("Fiyat Degisikligi | %s | %s | %d urun", group, now.Format("02.01.2006 15:04"), len(list))
|
||||
html := buildPricingChangeMailHTML(group, list, actor, now)
|
||||
|
||||
// Retry 2 times with backoff.
|
||||
backoff := []time.Duration{800 * time.Millisecond, 2500 * time.Millisecond}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < len(backoff)+1; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(backoff[attempt-1])
|
||||
}
|
||||
stepCtx, stepCancel := context.WithTimeout(bg, 25*time.Second)
|
||||
err := ml.Send(stepCtx, mailer.Message{
|
||||
To: recipients,
|
||||
Subject: subject,
|
||||
BodyHTML: html,
|
||||
})
|
||||
stepCancel()
|
||||
if err == nil {
|
||||
lastErr = nil
|
||||
break
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr != nil {
|
||||
log.Printf("[pricing-mail] send failed group=%s err=%v", group, lastErr)
|
||||
} else {
|
||||
log.Printf("[pricing-mail] sent group=%s to=%d products=%d", group, len(recipients), len(list))
|
||||
}
|
||||
}
|
||||
}
|
||||
505
svc/routes/product_pricing_history.go
Normal file
505
svc/routes/product_pricing_history.go
Normal file
@@ -0,0 +1,505 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/queries"
|
||||
"bssapp-backend/utils"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type productPricingHistoryPGRow struct {
|
||||
ID string `json:"id"`
|
||||
Currency string `json:"currency"`
|
||||
LevelNo int `json:"level_no"`
|
||||
Price float64 `json:"price"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
SdprcGrpID int `json:"sdprcgrp_id"`
|
||||
}
|
||||
|
||||
type productPricingHistoryMSSQLRow struct {
|
||||
PriceListLineID string `json:"price_list_line_id"`
|
||||
Currency string `json:"currency"`
|
||||
PriceGroupCode string `json:"price_group_code"`
|
||||
Price float64 `json:"price"`
|
||||
ValidDate string `json:"valid_date"`
|
||||
ValidTime string `json:"valid_time"`
|
||||
LastUpdatedDate string `json:"last_updated_date"`
|
||||
IsDisabled bool `json:"is_disabled"`
|
||||
}
|
||||
|
||||
type productPricingHistoryResponse struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
Postgres []productPricingHistoryPGRow `json:"postgres"`
|
||||
Mssql []productPricingHistoryMSSQLRow `json:"mssql"`
|
||||
}
|
||||
|
||||
func GetProductPricingHistoryHandler(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)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
productCode := strings.TrimSpace(mux.Vars(r)["code"])
|
||||
if productCode == "" {
|
||||
http.Error(w, "product code required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Load nebim price groups from PG mapping (18) + base groups (2).
|
||||
priceGroups := []string{"TM-USD", "TM-TRY"}
|
||||
if pg != nil {
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT DISTINCT COALESCE(NULLIF(BTRIM(price_group_code), ''), '')
|
||||
FROM mk_price_target_map_nebim
|
||||
WHERE is_active = TRUE
|
||||
`)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var code string
|
||||
if err := rows.Scan(&code); err != nil {
|
||||
_ = rows.Close()
|
||||
break
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code != "" {
|
||||
priceGroups = append(priceGroups, code)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
resp := productPricingHistoryResponse{
|
||||
ProductCode: productCode,
|
||||
Postgres: []productPricingHistoryPGRow{},
|
||||
Mssql: []productPricingHistoryMSSQLRow{},
|
||||
}
|
||||
|
||||
// Postgres sdprc history.
|
||||
if pg != nil {
|
||||
pgRows, err := pg.QueryContext(ctx, `
|
||||
SELECT
|
||||
sdprc.id::text,
|
||||
sdprc.crn,
|
||||
sdprc.sdprcgrp_id,
|
||||
COALESCE(sdprc.prc, 0)::float8,
|
||||
TO_CHAR(sdprc.zlins_dttm, 'YYYY-MM-DD HH24:MI:SS')
|
||||
FROM sdprc
|
||||
JOIN mmitem ON mmitem.id = sdprc.mmitem_id
|
||||
WHERE mmitem.code = $1
|
||||
AND sdprc.crn IN ('USD','EUR','TRY')
|
||||
AND sdprc.sdprcgrp_id BETWEEN 1 AND 6
|
||||
ORDER BY sdprc.zlins_dttm DESC
|
||||
LIMIT 400;
|
||||
`, productCode)
|
||||
if err == nil {
|
||||
for pgRows.Next() {
|
||||
var id, cur, at string
|
||||
var grp int
|
||||
var prc float64
|
||||
if err := pgRows.Scan(&id, &cur, &grp, &prc, &at); err != nil {
|
||||
_ = pgRows.Close()
|
||||
http.Error(w, "pg history scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resp.Postgres = append(resp.Postgres, productPricingHistoryPGRow{
|
||||
ID: strings.TrimSpace(id),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(cur)),
|
||||
SdprcGrpID: grp,
|
||||
LevelNo: grp,
|
||||
Price: prc,
|
||||
UpdatedAt: strings.TrimSpace(at),
|
||||
})
|
||||
}
|
||||
_ = pgRows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// MSSQL trPriceListLine history (only relevant price groups).
|
||||
mssql := db.GetDB()
|
||||
if mssql != nil {
|
||||
// Build a safe "IN" via OR parameters.
|
||||
conds := make([]string, 0, len(priceGroups))
|
||||
args := make([]any, 0, len(priceGroups)+1)
|
||||
args = append(args, sql.Named("p1", productCode))
|
||||
for i, g := range priceGroups {
|
||||
name := fmt.Sprintf("g%d", i+1)
|
||||
conds = append(conds, "LTRIM(RTRIM(p.PriceGroupCode)) = @"+name)
|
||||
args = append(args, sql.Named(name, g))
|
||||
}
|
||||
wherePG := "1=0"
|
||||
if len(conds) > 0 {
|
||||
wherePG = "(" + strings.Join(conds, " OR ") + ")"
|
||||
}
|
||||
q := `
|
||||
SELECT TOP (400)
|
||||
CONVERT(NVARCHAR(36), p.PriceListLineID) AS PriceListLineID,
|
||||
LTRIM(RTRIM(p.DocCurrencyCode)) AS DocCurrencyCode,
|
||||
LTRIM(RTRIM(p.PriceGroupCode)) AS PriceGroupCode,
|
||||
CAST(p.Price AS FLOAT) AS Price,
|
||||
CONVERT(VARCHAR(10), p.ValidDate, 23) AS ValidDate,
|
||||
CONVERT(VARCHAR(8), p.ValidTime, 108) AS ValidTime,
|
||||
CONVERT(VARCHAR(19), p.LastUpdatedDate, 120) AS LastUpdatedDate,
|
||||
CAST(ISNULL(p.IsDisabled, 0) AS BIT) AS IsDisabled
|
||||
FROM dbo.trPriceListLine p WITH(NOLOCK)
|
||||
WHERE p.ItemTypeCode = 1
|
||||
AND LTRIM(RTRIM(p.ItemCode)) = @p1
|
||||
AND ` + wherePG + `
|
||||
ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC;
|
||||
`
|
||||
rows, err := mssql.QueryContext(ctx, q, args...)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var id, cur, grp, vd, vt, lud string
|
||||
var prc float64
|
||||
var disabled bool
|
||||
if err := rows.Scan(&id, &cur, &grp, &prc, &vd, &vt, &lud, &disabled); err != nil {
|
||||
_ = rows.Close()
|
||||
http.Error(w, "mssql history scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resp.Mssql = append(resp.Mssql, productPricingHistoryMSSQLRow{
|
||||
PriceListLineID: strings.TrimSpace(id),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(cur)),
|
||||
PriceGroupCode: strings.TrimSpace(grp),
|
||||
Price: prc,
|
||||
ValidDate: strings.TrimSpace(vd),
|
||||
ValidTime: strings.TrimSpace(vt),
|
||||
LastUpdatedDate: strings.TrimSpace(lud),
|
||||
IsDisabled: disabled,
|
||||
})
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}
|
||||
|
||||
type deleteLatestPriceHistoryRequest struct {
|
||||
DeletePostgres bool `json:"delete_postgres"`
|
||||
DeleteMssql bool `json:"delete_mssql"`
|
||||
Currency string `json:"currency"` // USD/EUR/TRY
|
||||
LevelNo int `json:"level_no"` // 1..6 (tier); for base use 0
|
||||
IsBase bool `json:"is_base"`
|
||||
PriceGroupCode string `json:"price_group_code"` // optional override for MSSQL deletes
|
||||
}
|
||||
|
||||
func PostDeleteLatestProductPriceHistoryHandler(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)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
productCode := strings.TrimSpace(mux.Vars(r)["code"])
|
||||
if productCode == "" {
|
||||
http.Error(w, "product code required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req deleteLatestPriceHistoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !req.DeletePostgres && !req.DeleteMssql {
|
||||
req.DeletePostgres = true
|
||||
req.DeleteMssql = true
|
||||
}
|
||||
|
||||
cur := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||
if cur != "USD" && cur != "EUR" && cur != "TRY" {
|
||||
http.Error(w, "invalid currency", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !req.IsBase && req.DeletePostgres && (req.LevelNo < 1 || req.LevelNo > 6) {
|
||||
http.Error(w, "invalid level_no", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// PG delete (sdprc).
|
||||
deletedPG := int64(0)
|
||||
if req.DeletePostgres && !req.IsBase && pg != nil {
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "pg tx error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var mmItemID int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, productCode).Scan(&mmItemID); err != nil {
|
||||
http.Error(w, "pg product not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
grp := req.LevelNo
|
||||
// Delete latest row for that currency+level.
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM sdprc
|
||||
WHERE id = (
|
||||
SELECT id
|
||||
FROM sdprc
|
||||
WHERE mmitem_id=$1 AND crn=$2 AND sdprcgrp_id=$3
|
||||
ORDER BY zlins_dttm DESC
|
||||
LIMIT 1
|
||||
);
|
||||
`, mmItemID, cur, grp)
|
||||
if err != nil {
|
||||
http.Error(w, "pg delete error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
deletedPG, _ = res.RowsAffected()
|
||||
|
||||
// enqueue delta recompute for this product to keep derived currencies consistent
|
||||
_, _ = queries.EnqueuePriceRecalc(ctx, tx, []string{productCode}, "history_delete")
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, "pg commit error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// MSSQL delete (trPriceListLine).
|
||||
deletedMSSQL := int64(0)
|
||||
if req.DeleteMssql {
|
||||
mssql := db.GetDB()
|
||||
if mssql == nil {
|
||||
http.Error(w, "mssql not connected", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tx, err := mssql.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "mssql tx error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
priceGroup := strings.TrimSpace(req.PriceGroupCode)
|
||||
if req.IsBase {
|
||||
if cur == "USD" {
|
||||
priceGroup = "TM-USD"
|
||||
} else if cur == "TRY" {
|
||||
priceGroup = "TM-TRY"
|
||||
} else {
|
||||
http.Error(w, "base only supports USD/TRY", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else if priceGroup == "" && pg != nil {
|
||||
_ = pg.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(NULLIF(BTRIM(price_group_code), ''), '')
|
||||
FROM mk_price_target_map_nebim
|
||||
WHERE is_active=TRUE AND currency=$1 AND level_no=$2
|
||||
`, cur, req.LevelNo).Scan(&priceGroup)
|
||||
}
|
||||
priceGroup = strings.TrimSpace(priceGroup)
|
||||
if priceGroup == "" {
|
||||
http.Error(w, "missing price group mapping", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
;WITH latest AS (
|
||||
SELECT TOP (1) p.PriceListLineID
|
||||
FROM dbo.trPriceListLine p WITH(UPDLOCK, ROWLOCK)
|
||||
WHERE p.ItemTypeCode=1
|
||||
AND LTRIM(RTRIM(p.ItemCode))=@p1
|
||||
AND LTRIM(RTRIM(p.DocCurrencyCode))=@p2
|
||||
AND LTRIM(RTRIM(p.PriceGroupCode))=@p3
|
||||
AND ISNULL(p.IsDisabled, 0)=0
|
||||
ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC
|
||||
)
|
||||
DELETE FROM dbo.trPriceListLine
|
||||
WHERE PriceListLineID IN (SELECT PriceListLineID FROM latest);
|
||||
`, sql.Named("p1", productCode), sql.Named("p2", cur), sql.Named("p3", priceGroup))
|
||||
if err != nil {
|
||||
http.Error(w, "mssql delete error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
deletedMSSQL, _ = res.RowsAffected()
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, "mssql commit error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"product_code": productCode,
|
||||
"deleted_pg": deletedPG,
|
||||
"deleted_mssql": deletedMSSQL,
|
||||
"actor_user": claims.Username,
|
||||
"actor_user_id": claims.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type deleteSelectedPriceHistoryRequest struct {
|
||||
PGIDs []string `json:"pg_ids"` // sdprc.id (uuid)
|
||||
MSSQLIDs []string `json:"mssql_ids"` // trPriceListLine.PriceListLineID (uuid)
|
||||
}
|
||||
|
||||
func PostDeleteSelectedProductPriceHistoryHandler(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)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
productCode := strings.TrimSpace(mux.Vars(r)["code"])
|
||||
if productCode == "" {
|
||||
http.Error(w, "product code required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req deleteSelectedPriceHistoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// normalize ids
|
||||
pgIDs := make([]string, 0, len(req.PGIDs))
|
||||
for _, x := range req.PGIDs {
|
||||
s := strings.TrimSpace(x)
|
||||
if s != "" {
|
||||
pgIDs = append(pgIDs, s)
|
||||
}
|
||||
}
|
||||
msIDs := make([]string, 0, len(req.MSSQLIDs))
|
||||
for _, x := range req.MSSQLIDs {
|
||||
s := strings.TrimSpace(x)
|
||||
if s != "" {
|
||||
msIDs = append(msIDs, s)
|
||||
}
|
||||
}
|
||||
if len(pgIDs) == 0 && len(msIDs) == 0 {
|
||||
http.Error(w, "no ids selected", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deletedPG := int64(0)
|
||||
if len(pgIDs) > 0 && pg != nil {
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "pg tx error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Resolve product id to constrain deletes to the given productCode.
|
||||
var mmItemID int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, productCode).Scan(&mmItemID); err != nil {
|
||||
http.Error(w, "pg product not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete only rows matching mmitem_id + id list.
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM sdprc
|
||||
WHERE mmitem_id = $1
|
||||
AND id = ANY($2::uuid[]);
|
||||
`, mmItemID, pq.Array(pgIDs))
|
||||
if err != nil {
|
||||
http.Error(w, "pg delete error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
deletedPG, _ = res.RowsAffected()
|
||||
|
||||
_, _ = queries.EnqueuePriceRecalc(ctx, tx, []string{productCode}, "history_delete_selected")
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, "pg commit error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
deletedMSSQL := int64(0)
|
||||
if len(msIDs) > 0 {
|
||||
mssql := db.GetDB()
|
||||
if mssql == nil {
|
||||
http.Error(w, "mssql not connected", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tx, err := mssql.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "mssql tx error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Build a safe IN-list via named parameters.
|
||||
placeholders := make([]string, 0, len(msIDs))
|
||||
args := make([]any, 0, len(msIDs)+1)
|
||||
args = append(args, sql.Named("p1", productCode))
|
||||
for i, id := range msIDs {
|
||||
name := fmt.Sprintf("id%d", i+1)
|
||||
placeholders = append(placeholders, "@"+name)
|
||||
args = append(args, sql.Named(name, id))
|
||||
}
|
||||
|
||||
q := `
|
||||
DELETE FROM dbo.trPriceListLine
|
||||
WHERE ItemTypeCode = 1
|
||||
AND LTRIM(RTRIM(ItemCode)) = @p1
|
||||
AND PriceListLineID IN (` + strings.Join(placeholders, ",") + `);
|
||||
`
|
||||
res, err := tx.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
http.Error(w, "mssql delete error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
deletedMSSQL, _ = res.RowsAffected()
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, "mssql commit error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"product_code": productCode,
|
||||
"deleted_pg": deletedPG,
|
||||
"deleted_mssql": deletedMSSQL,
|
||||
"actor_user": claims.Username,
|
||||
"actor_user_id": claims.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
492
svc/routes/product_pricing_price_list_export.go
Normal file
492
svc/routes/product_pricing_price_list_export.go
Normal file
@@ -0,0 +1,492 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/models"
|
||||
"bssapp-backend/queries"
|
||||
"bssapp-backend/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type priceListExportRequest struct {
|
||||
// Product filters (same semantics as listing)
|
||||
ProductCode []string `json:"product_code"`
|
||||
BrandGroup []string `json:"brand_group"`
|
||||
AskiliYan []string `json:"askili_yan"`
|
||||
Kategori []string `json:"kategori"`
|
||||
UrunIlkGrubu []string `json:"urun_ilk_grubu"`
|
||||
UrunAnaGrubu []string `json:"urun_ana_grubu"`
|
||||
UrunAltGrubu []string `json:"urun_alt_grubu"`
|
||||
Icerik []string `json:"icerik"`
|
||||
Karisim []string `json:"karisim"`
|
||||
Marka []string `json:"marka"`
|
||||
Search string `json:"search"`
|
||||
|
||||
InStockOnly bool `json:"in_stock_only"`
|
||||
|
||||
// Column selection
|
||||
IncludeMeta bool `json:"include_meta"`
|
||||
IncludeCost bool `json:"include_cost"`
|
||||
IncludeBase bool `json:"include_base"`
|
||||
|
||||
USDLevels []int `json:"usd_levels"` // 1..6
|
||||
EURLevels []int `json:"eur_levels"` // 1..6
|
||||
TRYLevels []int `json:"try_levels"` // 1..6
|
||||
}
|
||||
|
||||
type exportCol struct {
|
||||
Key string
|
||||
Title string
|
||||
Width float64
|
||||
Align string // L/R/C for PDF
|
||||
}
|
||||
|
||||
func cleanLevels(in []int) []int {
|
||||
out := make([]int, 0, len(in))
|
||||
seen := map[int]struct{}{}
|
||||
for _, v := range in {
|
||||
if v < 1 || v > 6 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func resolvePriceListColumns(req priceListExportRequest) []exportCol {
|
||||
cols := make([]exportCol, 0, 64)
|
||||
|
||||
if req.IncludeMeta {
|
||||
cols = append(cols,
|
||||
exportCol{Key: "BrandGroupSec", Title: "MARKA GRUBU", Width: 26, Align: "L"},
|
||||
exportCol{Key: "Marka", Title: "MARKA", Width: 18, Align: "L"},
|
||||
exportCol{Key: "BrandCode", Title: "BRAND CODE", Width: 18, Align: "L"},
|
||||
exportCol{Key: "ProductCode", Title: "URUN KODU", Width: 22, Align: "L"},
|
||||
exportCol{Key: "StockQty", Title: "STOK ADET", Width: 16, Align: "R"},
|
||||
exportCol{Key: "StockEntryDate", Title: "STOK GIRIS", Width: 18, Align: "C"},
|
||||
exportCol{Key: "LastCostingDate", Title: "SON MALIYET", Width: 18, Align: "C"},
|
||||
exportCol{Key: "LastPricingDate", Title: "SON FIYAT", Width: 18, Align: "C"},
|
||||
exportCol{Key: "AskiliYan", Title: "ASKILI YAN", Width: 18, Align: "L"},
|
||||
exportCol{Key: "Kategori", Title: "KATEGORI", Width: 18, Align: "L"},
|
||||
exportCol{Key: "UrunIlkGrubu", Title: "URUN ILK GRUBU", Width: 20, Align: "L"},
|
||||
exportCol{Key: "UrunAnaGrubu", Title: "URUN ANA GRUBU", Width: 20, Align: "L"},
|
||||
exportCol{Key: "UrunAltGrubu", Title: "URUN ALT GRUBU", Width: 20, Align: "L"},
|
||||
exportCol{Key: "Icerik", Title: "ICERIK", Width: 18, Align: "L"},
|
||||
exportCol{Key: "Karisim", Title: "KARISIM", Width: 18, Align: "L"},
|
||||
)
|
||||
}
|
||||
if req.IncludeCost {
|
||||
cols = append(cols, exportCol{Key: "CostPrice", Title: "MALIYET FIYATI", Width: 16, Align: "R"})
|
||||
}
|
||||
if req.IncludeBase {
|
||||
cols = append(cols,
|
||||
exportCol{Key: "BasePriceUsd", Title: "TABAN USD", Width: 14, Align: "R"},
|
||||
exportCol{Key: "BasePriceTry", Title: "TABAN TRY", Width: 14, Align: "R"},
|
||||
)
|
||||
}
|
||||
|
||||
usd := cleanLevels(req.USDLevels)
|
||||
eur := cleanLevels(req.EURLevels)
|
||||
tr := cleanLevels(req.TRYLevels)
|
||||
for _, lv := range usd {
|
||||
cols = append(cols, exportCol{Key: fmt.Sprintf("USD%d", lv), Title: fmt.Sprintf("USD %d", lv), Width: 12, Align: "R"})
|
||||
}
|
||||
for _, lv := range eur {
|
||||
cols = append(cols, exportCol{Key: fmt.Sprintf("EUR%d", lv), Title: fmt.Sprintf("EUR %d", lv), Width: 12, Align: "R"})
|
||||
}
|
||||
for _, lv := range tr {
|
||||
cols = append(cols, exportCol{Key: fmt.Sprintf("TRY%d", lv), Title: fmt.Sprintf("TRY %d", lv), Width: 12, Align: "R"})
|
||||
}
|
||||
|
||||
return cols
|
||||
}
|
||||
|
||||
func fmtMoneyCell(v float64) string {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%.2f", v)
|
||||
}
|
||||
|
||||
func getCellValue(row models.ProductPricing, key string) string {
|
||||
switch key {
|
||||
case "BrandGroupSec":
|
||||
return strings.TrimSpace(row.BrandGroupSec)
|
||||
case "Marka":
|
||||
return strings.TrimSpace(row.Marka)
|
||||
case "BrandCode":
|
||||
return strings.TrimSpace(row.BrandCode)
|
||||
case "ProductCode":
|
||||
return strings.TrimSpace(row.ProductCode)
|
||||
case "StockQty":
|
||||
return fmtMoneyCell(row.StockQty)
|
||||
case "StockEntryDate":
|
||||
return strings.TrimSpace(row.StockEntryDate)
|
||||
case "LastCostingDate":
|
||||
return strings.TrimSpace(row.LastCostingDate)
|
||||
case "LastPricingDate":
|
||||
return strings.TrimSpace(row.LastPricingDate)
|
||||
case "AskiliYan":
|
||||
return strings.TrimSpace(row.AskiliYan)
|
||||
case "Kategori":
|
||||
return strings.TrimSpace(row.Kategori)
|
||||
case "UrunIlkGrubu":
|
||||
return strings.TrimSpace(row.UrunIlkGrubu)
|
||||
case "UrunAnaGrubu":
|
||||
return strings.TrimSpace(row.UrunAnaGrubu)
|
||||
case "UrunAltGrubu":
|
||||
return strings.TrimSpace(row.UrunAltGrubu)
|
||||
case "Icerik":
|
||||
return strings.TrimSpace(row.Icerik)
|
||||
case "Karisim":
|
||||
return strings.TrimSpace(row.Karisim)
|
||||
case "CostPrice":
|
||||
return fmtMoneyCell(row.CostPrice)
|
||||
case "BasePriceUsd":
|
||||
return fmtMoneyCell(row.BasePriceUsd)
|
||||
case "BasePriceTry":
|
||||
return fmtMoneyCell(row.BasePriceTry)
|
||||
case "USD1":
|
||||
return fmtMoneyCell(row.USD1)
|
||||
case "USD2":
|
||||
return fmtMoneyCell(row.USD2)
|
||||
case "USD3":
|
||||
return fmtMoneyCell(row.USD3)
|
||||
case "USD4":
|
||||
return fmtMoneyCell(row.USD4)
|
||||
case "USD5":
|
||||
return fmtMoneyCell(row.USD5)
|
||||
case "USD6":
|
||||
return fmtMoneyCell(row.USD6)
|
||||
case "EUR1":
|
||||
return fmtMoneyCell(row.EUR1)
|
||||
case "EUR2":
|
||||
return fmtMoneyCell(row.EUR2)
|
||||
case "EUR3":
|
||||
return fmtMoneyCell(row.EUR3)
|
||||
case "EUR4":
|
||||
return fmtMoneyCell(row.EUR4)
|
||||
case "EUR5":
|
||||
return fmtMoneyCell(row.EUR5)
|
||||
case "EUR6":
|
||||
return fmtMoneyCell(row.EUR6)
|
||||
case "TRY1":
|
||||
return fmtMoneyCell(row.TRY1)
|
||||
case "TRY2":
|
||||
return fmtMoneyCell(row.TRY2)
|
||||
case "TRY3":
|
||||
return fmtMoneyCell(row.TRY3)
|
||||
case "TRY4":
|
||||
return fmtMoneyCell(row.TRY4)
|
||||
case "TRY5":
|
||||
return fmtMoneyCell(row.TRY5)
|
||||
case "TRY6":
|
||||
return fmtMoneyCell(row.TRY6)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ExportProductPriceListExcelHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req priceListExportRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !req.IncludeMeta && !req.IncludeCost && !req.IncludeBase && len(req.USDLevels) == 0 && len(req.EURLevels) == 0 && len(req.TRYLevels) == 0 {
|
||||
req.IncludeMeta = true
|
||||
req.IncludeCost = true
|
||||
req.IncludeBase = true
|
||||
req.USDLevels = []int{1, 2, 3, 4, 5, 6}
|
||||
req.EURLevels = []int{1, 2, 3, 4, 5, 6}
|
||||
req.TRYLevels = []int{1, 2, 3, 4, 5, 6}
|
||||
}
|
||||
if req.IncludeMeta == false {
|
||||
req.IncludeMeta = true
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
filters := queries.ProductPricingFilters{
|
||||
Search: strings.TrimSpace(req.Search),
|
||||
ProductCode: req.ProductCode,
|
||||
BrandGroup: req.BrandGroup,
|
||||
AskiliYan: req.AskiliYan,
|
||||
Kategori: req.Kategori,
|
||||
UrunIlkGrubu: req.UrunIlkGrubu,
|
||||
UrunAnaGrubu: req.UrunAnaGrubu,
|
||||
UrunAltGrubu: req.UrunAltGrubu,
|
||||
Icerik: req.Icerik,
|
||||
Karisim: req.Karisim,
|
||||
Marka: req.Marka,
|
||||
}
|
||||
|
||||
rows, err := queries.GetAllProductPricingRows(ctx, 500, filters, "productCode", false)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if req.InStockOnly {
|
||||
tmp := make([]models.ProductPricing, 0, len(rows))
|
||||
for _, it := range rows {
|
||||
if it.StockQty > 0.0001 {
|
||||
tmp = append(tmp, it)
|
||||
}
|
||||
}
|
||||
rows = tmp
|
||||
}
|
||||
|
||||
cols := resolvePriceListColumns(req)
|
||||
|
||||
f := excelize.NewFile()
|
||||
defer func() { _ = f.Close() }()
|
||||
sheet := "Fiyat Listesi"
|
||||
f.SetSheetName("Sheet1", sheet)
|
||||
|
||||
now := time.Now()
|
||||
title := "BAGGI - GUNCEL FIYAT LISTESI"
|
||||
dateLine := "Tarih: " + now.Format("02.01.2006")
|
||||
|
||||
_ = f.SetCellValue(sheet, "A1", title)
|
||||
_ = f.SetCellValue(sheet, "A2", dateLine)
|
||||
_ = f.MergeCell(sheet, "A1", "H1")
|
||||
_ = f.MergeCell(sheet, "A2", "H2")
|
||||
|
||||
// Try to add logo (best-effort).
|
||||
if logoPath, err := resolvePdfImagePath("Baggi-Tekstil-A.s-Logolu.jpeg"); err == nil {
|
||||
_ = f.AddPicture(sheet, "I1", logoPath, &excelize.GraphicOptions{
|
||||
ScaleX: 0.25,
|
||||
ScaleY: 0.25,
|
||||
})
|
||||
}
|
||||
|
||||
// Header row
|
||||
headerRow := 4
|
||||
for i, c := range cols {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, headerRow)
|
||||
_ = f.SetCellValue(sheet, cell, c.Title)
|
||||
colName, _ := excelize.ColumnNumberToName(i + 1)
|
||||
_ = f.SetColWidth(sheet, colName, colName, c.Width)
|
||||
}
|
||||
// Freeze panes at header
|
||||
_ = f.SetPanes(sheet, &excelize.Panes{
|
||||
Freeze: true,
|
||||
Split: false,
|
||||
XSplit: 0,
|
||||
YSplit: headerRow,
|
||||
TopLeftCell: "A5",
|
||||
ActivePane: "bottomLeft",
|
||||
})
|
||||
|
||||
// Basic styles
|
||||
hStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Color: "#FFFFFF"},
|
||||
Fill: excelize.Fill{Type: "pattern", Color: []string{"#957116"}, Pattern: 1},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: "#C0C0C0", Style: 1},
|
||||
{Type: "top", Color: "#C0C0C0", Style: 1},
|
||||
{Type: "bottom", Color: "#C0C0C0", Style: 1},
|
||||
{Type: "right", Color: "#C0C0C0", Style: 1},
|
||||
},
|
||||
})
|
||||
lastHeaderCell, _ := excelize.CoordinatesToCellName(len(cols), headerRow)
|
||||
_ = f.SetCellStyle(sheet, "A4", lastHeaderCell, hStyle)
|
||||
|
||||
// Data rows
|
||||
startRow := headerRow + 1
|
||||
for ri, row := range rows {
|
||||
excelRow := startRow + ri
|
||||
for ci, c := range cols {
|
||||
cell, _ := excelize.CoordinatesToCellName(ci+1, excelRow)
|
||||
_ = f.SetCellValue(sheet, cell, getCellValue(row, c.Key))
|
||||
}
|
||||
}
|
||||
|
||||
// Autofilter
|
||||
_ = f.AutoFilter(sheet, fmt.Sprintf("A4:%s", lastHeaderCell), []excelize.AutoFilterOptions{})
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
http.Error(w, "excel write error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fmt.Sprintf("baggi_guncel_fiyat_listesi_%s.xlsx", now.Format("20060102"))))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func ExportProductPriceListPDFHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req priceListExportRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !req.IncludeMeta && !req.IncludeCost && !req.IncludeBase && len(req.USDLevels) == 0 && len(req.EURLevels) == 0 && len(req.TRYLevels) == 0 {
|
||||
req.IncludeMeta = true
|
||||
req.IncludeCost = true
|
||||
req.IncludeBase = true
|
||||
req.USDLevels = []int{1, 2, 3, 4, 5, 6}
|
||||
req.EURLevels = []int{1, 2, 3, 4, 5, 6}
|
||||
req.TRYLevels = []int{1, 2, 3, 4, 5, 6}
|
||||
}
|
||||
req.IncludeMeta = true
|
||||
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
filters := queries.ProductPricingFilters{
|
||||
Search: strings.TrimSpace(req.Search),
|
||||
ProductCode: req.ProductCode,
|
||||
BrandGroup: req.BrandGroup,
|
||||
AskiliYan: req.AskiliYan,
|
||||
Kategori: req.Kategori,
|
||||
UrunIlkGrubu: req.UrunIlkGrubu,
|
||||
UrunAnaGrubu: req.UrunAnaGrubu,
|
||||
UrunAltGrubu: req.UrunAltGrubu,
|
||||
Icerik: req.Icerik,
|
||||
Karisim: req.Karisim,
|
||||
Marka: req.Marka,
|
||||
}
|
||||
rows, err := queries.GetAllProductPricingRows(ctx, 500, filters, "productCode", false)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if req.InStockOnly {
|
||||
tmp := make([]models.ProductPricing, 0, len(rows))
|
||||
for _, it := range rows {
|
||||
if it.StockQty > 0.0001 {
|
||||
tmp = append(tmp, it)
|
||||
}
|
||||
}
|
||||
rows = tmp
|
||||
}
|
||||
|
||||
cols := resolvePriceListColumns(req)
|
||||
|
||||
pdf := gofpdf.New("L", "mm", "A4", "")
|
||||
pdf.SetMargins(8, 8, 8)
|
||||
pdf.SetAutoPageBreak(true, 10)
|
||||
_ = registerDejavuFonts(pdf, "dejavu")
|
||||
pdf.AddPage()
|
||||
|
||||
// Header: logo + title + date
|
||||
y := 10.0
|
||||
if logoPath, err := resolvePdfImagePath("Baggi-Tekstil-A.s-Logolu.jpeg"); err == nil {
|
||||
pdf.ImageOptions(logoPath, 8, y-2, 26, 0, false, gofpdf.ImageOptions{}, 0, "")
|
||||
}
|
||||
pdf.SetFont("dejavu", "B", 14)
|
||||
pdf.SetTextColor(149, 113, 22)
|
||||
pdf.SetXY(36, y)
|
||||
pdf.CellFormat(0, 7, "BAGGI - GUNCEL FIYAT LISTESI", "", 0, "L", false, 0, "")
|
||||
pdf.SetTextColor(0, 0, 0)
|
||||
pdf.SetFont("dejavu", "", 9)
|
||||
pdf.SetXY(36, y+7)
|
||||
pdf.CellFormat(0, 5, "Tarih: "+time.Now().Format("02.01.2006"), "", 0, "L", false, 0, "")
|
||||
pdf.SetXY(36, y+12)
|
||||
pdf.CellFormat(0, 5, "Olusturan: "+strings.TrimSpace(claims.Username), "", 0, "L", false, 0, "")
|
||||
|
||||
pdf.Ln(18)
|
||||
|
||||
pageW, _ := pdf.GetPageSize()
|
||||
availW := pageW - 16
|
||||
sumW := 0.0
|
||||
for _, c := range cols {
|
||||
sumW += c.Width
|
||||
}
|
||||
scale := 1.0
|
||||
if sumW > 0 && sumW > availW {
|
||||
scale = availW / sumW
|
||||
}
|
||||
|
||||
drawRow := func(isHeader bool, values []string) {
|
||||
h := 6.0
|
||||
if isHeader {
|
||||
pdf.SetFillColor(149, 113, 22)
|
||||
pdf.SetTextColor(255, 255, 255)
|
||||
pdf.SetFont("dejavu", "B", 7)
|
||||
} else {
|
||||
pdf.SetFillColor(255, 255, 255)
|
||||
pdf.SetTextColor(0, 0, 0)
|
||||
pdf.SetFont("dejavu", "", 7)
|
||||
}
|
||||
for i, c := range cols {
|
||||
w := c.Width * scale
|
||||
align := c.Align
|
||||
if align == "" {
|
||||
align = "L"
|
||||
}
|
||||
txt := ""
|
||||
if i < len(values) {
|
||||
txt = values[i]
|
||||
}
|
||||
pdf.CellFormat(w, h, txt, "1", 0, align, isHeader, 0, "")
|
||||
}
|
||||
pdf.Ln(-1)
|
||||
}
|
||||
|
||||
// Header row
|
||||
headerVals := make([]string, 0, len(cols))
|
||||
for _, c := range cols {
|
||||
headerVals = append(headerVals, c.Title)
|
||||
}
|
||||
drawRow(true, headerVals)
|
||||
|
||||
for _, row := range rows {
|
||||
vals := make([]string, 0, len(cols))
|
||||
for _, c := range cols {
|
||||
vals = append(vals, getCellValue(row, c.Key))
|
||||
}
|
||||
drawRow(false, vals)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := pdf.Output(&buf); err != nil {
|
||||
http.Error(w, "pdf render error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fmt.Sprintf("baggi_guncel_fiyat_listesi_%s.pdf", now.Format("20060102"))))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
}
|
||||
1195
svc/routes/product_pricing_save.go
Normal file
1195
svc/routes/product_pricing_save.go
Normal file
@@ -0,0 +1,1195 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/internal/mailer"
|
||||
"bssapp-backend/queries"
|
||||
"bssapp-backend/utils"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type productPricingSaveItem struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
|
||||
BasePriceUsd float64 `json:"base_price_usd"`
|
||||
BasePriceTry float64 `json:"base_price_try"`
|
||||
|
||||
USD1 float64 `json:"usd1"`
|
||||
USD2 float64 `json:"usd2"`
|
||||
USD3 float64 `json:"usd3"`
|
||||
USD4 float64 `json:"usd4"`
|
||||
USD5 float64 `json:"usd5"`
|
||||
USD6 float64 `json:"usd6"`
|
||||
|
||||
EUR1 float64 `json:"eur1"`
|
||||
EUR2 float64 `json:"eur2"`
|
||||
EUR3 float64 `json:"eur3"`
|
||||
EUR4 float64 `json:"eur4"`
|
||||
EUR5 float64 `json:"eur5"`
|
||||
EUR6 float64 `json:"eur6"`
|
||||
|
||||
TRY1 float64 `json:"try1"`
|
||||
TRY2 float64 `json:"try2"`
|
||||
TRY3 float64 `json:"try3"`
|
||||
TRY4 float64 `json:"try4"`
|
||||
TRY5 float64 `json:"try5"`
|
||||
TRY6 float64 `json:"try6"`
|
||||
}
|
||||
|
||||
type productPricingSavePayload struct {
|
||||
Items []productPricingSaveItem `json:"items"`
|
||||
}
|
||||
|
||||
func resolveOrCreatePriceListHeaderID(ctx context.Context, tx *sql.Tx, priceGroup string, currency string, username string, logger *slog.Logger) (string, error) {
|
||||
priceGroup = strings.TrimSpace(priceGroup)
|
||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
||||
if priceGroup == "" {
|
||||
return "", fmt.Errorf("empty price group")
|
||||
}
|
||||
if currency != "USD" && currency != "EUR" && currency != "TRY" {
|
||||
return "", fmt.Errorf("invalid currency")
|
||||
}
|
||||
|
||||
// Try existing header for group+currency.
|
||||
var headerID string
|
||||
_ = tx.QueryRowContext(ctx, `
|
||||
SELECT TOP (1) CONVERT(NVARCHAR(36), PriceListHeaderID)
|
||||
FROM dbo.trPriceListHeader WITH (UPDLOCK, HOLDLOCK)
|
||||
WHERE CompanyCode = 1
|
||||
AND LTRIM(RTRIM(PriceGroupCode)) = @pg
|
||||
AND LTRIM(RTRIM(DocCurrencyCode)) = @cur
|
||||
ORDER BY ValidDate DESC, ValidTime DESC, LastUpdatedDate DESC;
|
||||
`, sql.Named("pg", priceGroup), sql.Named("cur", currency)).Scan(&headerID)
|
||||
headerID = strings.TrimSpace(headerID)
|
||||
if headerID != "" {
|
||||
logger.Info("save:mssql:header:resolved",
|
||||
"price_group", priceGroup,
|
||||
"currency", currency,
|
||||
"header_id", headerID,
|
||||
)
|
||||
return headerID, nil
|
||||
}
|
||||
|
||||
// Create header (PriceListNumber pattern: "1-<seq>").
|
||||
// Note: PriceListNumber is unique (constraint seen as UQ_trPriceListHeader_1), so compute next and retry on collisions.
|
||||
isTaxIncluded := 0
|
||||
if strings.HasPrefix(strings.ToUpper(priceGroup), "B2C-") {
|
||||
isTaxIncluded = 1
|
||||
}
|
||||
|
||||
var priceListNumber string
|
||||
var err error
|
||||
for attempt := 1; attempt <= 5; attempt++ {
|
||||
var nextSeq int64
|
||||
if err2 := tx.QueryRowContext(ctx, `
|
||||
SELECT ISNULL(MAX(CASE WHEN v.n >= 10000 THEN v.n END), 9999) + 1
|
||||
FROM dbo.trPriceListHeader h WITH (UPDLOCK, HOLDLOCK)
|
||||
CROSS APPLY (VALUES (
|
||||
SUBSTRING(LTRIM(RTRIM(h.PriceListNumber)),
|
||||
CHARINDEX('-', LTRIM(RTRIM(h.PriceListNumber))) + 1,
|
||||
50)
|
||||
)) s(sfx)
|
||||
CROSS APPLY (VALUES (
|
||||
CASE
|
||||
WHEN s.sfx NOT LIKE '%[^0-9]%' THEN CAST(s.sfx AS BIGINT)
|
||||
ELSE NULL
|
||||
END
|
||||
)) v(n)
|
||||
WHERE LTRIM(RTRIM(h.PriceListNumber)) LIKE '1-%'
|
||||
AND CHARINDEX('-', LTRIM(RTRIM(h.PriceListNumber))) > 0;
|
||||
`).Scan(&nextSeq); err2 != nil {
|
||||
// If we cannot compute the next sequence (SQL dialect/version), log and fall back to the starting point.
|
||||
logger.Error("save:mssql:header:nextseq:error",
|
||||
"price_group", priceGroup,
|
||||
"currency", currency,
|
||||
"attempt", attempt,
|
||||
"err", err2,
|
||||
)
|
||||
nextSeq = 10000
|
||||
}
|
||||
if nextSeq <= 0 {
|
||||
nextSeq = 10000
|
||||
}
|
||||
if nextSeq < 10000 {
|
||||
nextSeq = 10000
|
||||
}
|
||||
priceListNumber = fmt.Sprintf("1-%d", nextSeq)
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
DECLARE @HeaderID UNIQUEIDENTIFIER = NEWID();
|
||||
|
||||
INSERT INTO dbo.trPriceListHeader (
|
||||
PriceListHeaderID,
|
||||
PriceListNumber,
|
||||
PriceListDate,
|
||||
PriceListTime,
|
||||
PriceListTypeCode,
|
||||
CompanyCode,
|
||||
PriceGroupCode,
|
||||
ValidDate,
|
||||
ValidTime,
|
||||
DocCurrencyCode,
|
||||
Description,
|
||||
IsTaxIncluded,
|
||||
IsCompleted,
|
||||
IsPrinted,
|
||||
IsLocked,
|
||||
IsConfirmed,
|
||||
ConfirmedUserName,
|
||||
ConfirmedDate,
|
||||
ApplicationCode,
|
||||
ApplicationID,
|
||||
CreatedUserName,
|
||||
CreatedDate,
|
||||
LastUpdatedUserName,
|
||||
LastUpdatedDate
|
||||
)
|
||||
VALUES (
|
||||
@HeaderID,
|
||||
@PriceListNumber,
|
||||
CONVERT(date, GETDATE()),
|
||||
'00:00:00',
|
||||
'',
|
||||
1,
|
||||
@PriceGroupCode,
|
||||
CONVERT(date, GETDATE()),
|
||||
'00:00:00',
|
||||
@Currency,
|
||||
@Description,
|
||||
@IsTaxIncluded,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
@UserName,
|
||||
GETDATE(),
|
||||
'Price',
|
||||
CONVERT(NVARCHAR(36), @HeaderID),
|
||||
@UserName,
|
||||
GETDATE(),
|
||||
@UserName,
|
||||
GETDATE()
|
||||
);
|
||||
`, sql.Named("PriceListNumber", priceListNumber),
|
||||
sql.Named("PriceGroupCode", priceGroup),
|
||||
sql.Named("Currency", currency),
|
||||
sql.Named("Description", priceGroup),
|
||||
sql.Named("IsTaxIncluded", isTaxIncluded),
|
||||
sql.Named("UserName", username),
|
||||
)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
low := strings.ToLower(err.Error())
|
||||
if strings.Contains(low, "uq_trpricelistheader_1") || strings.Contains(low, "duplicate key") {
|
||||
logger.Warn("save:mssql:header:create:collision",
|
||||
"price_group", priceGroup,
|
||||
"currency", currency,
|
||||
"price_list_number", priceListNumber,
|
||||
"attempt", attempt,
|
||||
"err", err,
|
||||
)
|
||||
time.Sleep(time.Duration(20*attempt) * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create trPriceListHeader failed for PriceGroupCode=%s currency=%s: %w", priceGroup, currency, err)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create trPriceListHeader failed for PriceGroupCode=%s currency=%s: %w", priceGroup, currency, err)
|
||||
}
|
||||
|
||||
// Re-read header id.
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT TOP (1) CONVERT(NVARCHAR(36), PriceListHeaderID)
|
||||
FROM dbo.trPriceListHeader WITH (NOLOCK)
|
||||
WHERE CompanyCode = 1
|
||||
AND LTRIM(RTRIM(PriceGroupCode)) = @pg
|
||||
AND LTRIM(RTRIM(DocCurrencyCode)) = @cur
|
||||
ORDER BY CreatedDate DESC, LastUpdatedDate DESC;
|
||||
`, sql.Named("pg", priceGroup), sql.Named("cur", currency)).Scan(&headerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create header ok but cannot re-read header id: %w", err)
|
||||
}
|
||||
headerID = strings.TrimSpace(headerID)
|
||||
if headerID == "" {
|
||||
return "", fmt.Errorf("create header ok but header id is empty")
|
||||
}
|
||||
|
||||
logger.Info("save:mssql:header:created",
|
||||
"price_group", priceGroup,
|
||||
"currency", currency,
|
||||
"header_id", headerID,
|
||||
"price_list_number", priceListNumber,
|
||||
)
|
||||
return headerID, nil
|
||||
}
|
||||
|
||||
func PostProductPricingSaveHandler(pg *sql.DB, ml *mailer.GraphMailer) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
started := time.Now()
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var payload productPricingSavePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(payload.Items) == 0 {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "saved": 0})
|
||||
return
|
||||
}
|
||||
|
||||
// Basic validation early.
|
||||
for _, it := range payload.Items {
|
||||
if strings.TrimSpace(it.ProductCode) == "" {
|
||||
http.Error(w, "product_code is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if it.BasePriceUsd < 0 || it.BasePriceTry < 0 {
|
||||
http.Error(w, "base prices must be >= 0", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
ctx = utils.ContextWithTraceID(ctx, traceID)
|
||||
logger := utils.SlogFromContext(ctx).With("handler", "product-pricing.save", "trace_id", traceID, "user", claims.Username, "user_id", claims.ID)
|
||||
|
||||
mssql := db.GetDB()
|
||||
if mssql == nil {
|
||||
http.Error(w, "mssql not connected", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pgTx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "pg transaction start error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer pgTx.Rollback()
|
||||
|
||||
msTx, err := mssql.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "mssql transaction start error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer msTx.Rollback()
|
||||
|
||||
// Serialize writes to pricing tables in PG to avoid contention with other pricing jobs.
|
||||
if _, err := pgTx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(2001, 1)`); err != nil {
|
||||
http.Error(w, "pg advisory lock error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
savedPG := 0
|
||||
savedMSSQL := 0
|
||||
missingPG := 0
|
||||
missingMSSQL := 0
|
||||
|
||||
// Load mapping tables once.
|
||||
pgMap := map[string]map[int]int{} // currency -> level -> sdprcgrp_id
|
||||
nebimMap := map[string]map[int]string{} // currency -> level -> price_group_code
|
||||
|
||||
{
|
||||
rows, err := pgTx.QueryContext(ctx, `
|
||||
SELECT currency, level_no, COALESCE(sdprcgrp_id, 0)
|
||||
FROM mk_price_target_map_pg
|
||||
WHERE is_active = TRUE
|
||||
`)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var cur string
|
||||
var level int
|
||||
var grp int
|
||||
if err := rows.Scan(&cur, &level, &grp); err != nil {
|
||||
_ = rows.Close()
|
||||
http.Error(w, "pg map scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cur = strings.ToUpper(strings.TrimSpace(cur))
|
||||
if cur == "" || level <= 0 || level > 6 || grp <= 0 {
|
||||
continue
|
||||
}
|
||||
// In this setup sdprcgrp_id is expected to be 1..6. Guard against stale/invalid mappings.
|
||||
if grp < 1 || grp > 6 {
|
||||
continue
|
||||
}
|
||||
if pgMap[cur] == nil {
|
||||
pgMap[cur] = map[int]int{}
|
||||
}
|
||||
pgMap[cur][level] = grp
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
}
|
||||
{
|
||||
rows, err := pgTx.QueryContext(ctx, `
|
||||
SELECT currency, level_no, COALESCE(NULLIF(BTRIM(price_group_code), ''), '')
|
||||
FROM mk_price_target_map_nebim
|
||||
WHERE is_active = TRUE
|
||||
`)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var cur string
|
||||
var level int
|
||||
var code string
|
||||
if err := rows.Scan(&cur, &level, &code); err != nil {
|
||||
_ = rows.Close()
|
||||
http.Error(w, "nebim map scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cur = strings.ToUpper(strings.TrimSpace(cur))
|
||||
code = strings.TrimSpace(code)
|
||||
if cur == "" || level <= 0 || level > 6 || code == "" {
|
||||
continue
|
||||
}
|
||||
if nebimMap[cur] == nil {
|
||||
nebimMap[cur] = map[int]string{}
|
||||
}
|
||||
nebimMap[cur][level] = code
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
changed := make(map[string]struct{}, len(payload.Items))
|
||||
|
||||
// In-request cache to avoid repeating expensive dim resolution work.
|
||||
// Key: "<column>|<TOKEN>" where token is uppercased/trimmed.
|
||||
dimTokenLocalCache := make(map[string]int64, 256)
|
||||
|
||||
type dimCombo struct {
|
||||
Dim1 int64
|
||||
Dim3 sql.NullInt64
|
||||
}
|
||||
|
||||
type sdprcWriteRow struct {
|
||||
Currency string `json:"currency"`
|
||||
SdprcGrpID int `json:"sdprcgrp_id"`
|
||||
Dim1 int64 `json:"dim1"`
|
||||
Dim3 *int64 `json:"dim3"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
loadDimCombosFromCache := func(productCode string) ([]dimCombo, error) {
|
||||
productCode = strings.TrimSpace(productCode)
|
||||
if productCode == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := pgTx.QueryContext(ctx, `
|
||||
SELECT dim1, dim3
|
||||
FROM mk_mmitem_dim_combo
|
||||
WHERE product_code = $1
|
||||
ORDER BY dim1, dim3_key
|
||||
`, productCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]dimCombo, 0, 32)
|
||||
for rows.Next() {
|
||||
var d1 int64
|
||||
var d3 sql.NullInt64
|
||||
if err := rows.Scan(&d1, &d3); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d1 <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, dimCombo{Dim1: d1, Dim3: d3})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
parseDimID := func(s string) (int64, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
// tolerate leading zeros like "001"
|
||||
s2 := strings.TrimLeft(s, "0")
|
||||
if s2 == "" {
|
||||
s2 = "0"
|
||||
}
|
||||
n, err := strconv.ParseInt(s2, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
type queryRower interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
resolveDimvalFromToken := func(q queryRower, column, token string) (int64, bool) {
|
||||
token = strings.ToUpper(normalizeDimParam(token))
|
||||
if token == "" {
|
||||
return 0, false
|
||||
}
|
||||
cacheKey := column + "|" + token
|
||||
if v, ok := dimTokenLocalCache[cacheKey]; ok {
|
||||
return v, v > 0
|
||||
}
|
||||
|
||||
// Fast path: persistent token->id mapping table.
|
||||
{
|
||||
var id int64
|
||||
if err := pgTx.QueryRowContext(ctx, `
|
||||
SELECT dim_id
|
||||
FROM mk_dim_token_map
|
||||
WHERE dim_column = $1 AND token = $2
|
||||
`, column, token).Scan(&id); err == nil && id > 0 {
|
||||
dimTokenLocalCache[cacheKey] = id
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
|
||||
patterns := buildNameLikePatterns(token)
|
||||
if len(patterns) == 0 {
|
||||
dimTokenLocalCache[cacheKey] = 0
|
||||
return 0, false
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT x.dimv
|
||||
FROM (
|
||||
SELECT COALESCE(%s::text, '') AS dimv, COUNT(*) AS cnt
|
||||
FROM dfblob
|
||||
WHERE src_table='mmitem'
|
||||
AND typ='img'
|
||||
AND COALESCE(%s::text, '') <> ''
|
||||
AND (
|
||||
UPPER(COALESCE(file_name,'')) LIKE $1 OR
|
||||
UPPER(COALESCE(file_name,'')) LIKE $2 OR
|
||||
UPPER(COALESCE(file_name,'')) LIKE $3 OR
|
||||
UPPER(COALESCE(file_name,'')) LIKE $4 OR
|
||||
UPPER(COALESCE(file_name,'')) LIKE $5 OR
|
||||
UPPER(COALESCE(file_name,'')) LIKE $6
|
||||
)
|
||||
GROUP BY COALESCE(%s::text, '')
|
||||
) x
|
||||
ORDER BY x.cnt DESC, x.dimv
|
||||
LIMIT 1
|
||||
`, column, column, column)
|
||||
var v string
|
||||
if err := q.QueryRowContext(ctx,
|
||||
query,
|
||||
patterns[0],
|
||||
patterns[1],
|
||||
patterns[2],
|
||||
patterns[3],
|
||||
patterns[4],
|
||||
patterns[5],
|
||||
).Scan(&v); err != nil {
|
||||
dimTokenLocalCache[cacheKey] = 0
|
||||
return 0, false
|
||||
}
|
||||
v = normalizeDimParam(v)
|
||||
if v == "" {
|
||||
dimTokenLocalCache[cacheKey] = 0
|
||||
return 0, false
|
||||
}
|
||||
id, ok := parseDimID(v)
|
||||
if !ok {
|
||||
dimTokenLocalCache[cacheKey] = 0
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Persist for future requests (best-effort).
|
||||
_, _ = pgTx.ExecContext(ctx, `
|
||||
INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at)
|
||||
VALUES ($1,$2,$3,now())
|
||||
ON CONFLICT (dim_column, token)
|
||||
DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
|
||||
`, column, token, id)
|
||||
|
||||
dimTokenLocalCache[cacheKey] = id
|
||||
return id, true
|
||||
}
|
||||
|
||||
loadDimsFromMssqlStock := func(productCode string) ([]dimCombo, error) {
|
||||
started := time.Now()
|
||||
if db.MssqlDB == nil {
|
||||
return nil, fmt.Errorf("mssql not ready")
|
||||
}
|
||||
rows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductVariantDimsForPricing, productCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]dimCombo, 0, 32)
|
||||
seen := make(map[string]struct{}, 64)
|
||||
readRows := 0
|
||||
resolvedDim1 := 0
|
||||
resolvedDim3 := 0
|
||||
for rows.Next() {
|
||||
readRows++
|
||||
var colorCode, dim1Code, dim3Code string
|
||||
if err := rows.Scan(&colorCode, &dim1Code, &dim3Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Resolve to PG dim ids (e-commerce expects integer ids, e.g. dim1=82).
|
||||
d1 := int64(0)
|
||||
if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim1Code); ok {
|
||||
d1 = id
|
||||
resolvedDim1++
|
||||
} else if id, ok := resolveDimvalFromToken(pgTx, "dimval1", colorCode); ok {
|
||||
d1 = id
|
||||
resolvedDim1++
|
||||
}
|
||||
if d1 <= 0 {
|
||||
continue
|
||||
}
|
||||
var d3 sql.NullInt64
|
||||
if id, ok := resolveDimvalFromToken(pgTx, "dimval3", dim3Code); ok {
|
||||
d3 = sql.NullInt64{Int64: id, Valid: true}
|
||||
resolvedDim3++
|
||||
}
|
||||
key := fmt.Sprintf("%d|%d", d1, func() int64 {
|
||||
if d3.Valid {
|
||||
return d3.Int64
|
||||
}
|
||||
return 0
|
||||
}())
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, dimCombo{Dim1: d1, Dim3: d3})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("save:pg:dims:mssql:resolved",
|
||||
"product_code", strings.TrimSpace(productCode),
|
||||
"rows_read", readRows,
|
||||
"dims", len(out),
|
||||
"resolved_dim1", resolvedDim1,
|
||||
"resolved_dim3", resolvedDim3,
|
||||
"duration_ms", time.Since(started).Milliseconds(),
|
||||
)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
upsertDimCombosCache := func(productCode string, dims []dimCombo) error {
|
||||
productCode = strings.TrimSpace(productCode)
|
||||
if productCode == "" || len(dims) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, d := range dims {
|
||||
_, err := pgTx.ExecContext(ctx, `
|
||||
INSERT INTO mk_mmitem_dim_combo (product_code, dim1, dim3, updated_at)
|
||||
VALUES ($1,$2,$3,now())
|
||||
ON CONFLICT (product_code, dim1, dim3_key)
|
||||
DO UPDATE SET updated_at = EXCLUDED.updated_at
|
||||
`, productCode, d.Dim1, func() any {
|
||||
if d.Dim3.Valid {
|
||||
return d.Dim3.Int64
|
||||
}
|
||||
return nil
|
||||
}())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
bulkAppendOnlyInsertSdprc := func(mmItemID int64, productCode string, rows []sdprcWriteRow) (int, error) {
|
||||
if mmItemID <= 0 {
|
||||
return 0, fmt.Errorf("invalid mmitem_id")
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
raw, err := json.Marshal(rows)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
q := `
|
||||
WITH input AS (
|
||||
SELECT *
|
||||
FROM jsonb_to_recordset($1::jsonb) AS x(currency text, sdprcgrp_id int, dim1 bigint, dim3 bigint, price float8)
|
||||
),
|
||||
norm AS (
|
||||
SELECT
|
||||
UPPER(NULLIF(BTRIM(currency), '')) AS currency,
|
||||
COALESCE(sdprcgrp_id, 0) AS sdprcgrp_id,
|
||||
COALESCE(dim1, 0) AS dim1,
|
||||
dim3 AS dim3,
|
||||
COALESCE(price, 0) AS price
|
||||
FROM input
|
||||
),
|
||||
filtered AS (
|
||||
SELECT *
|
||||
FROM norm
|
||||
WHERE currency IN ('USD','EUR','TRY')
|
||||
AND sdprcgrp_id BETWEEN 1 AND 6
|
||||
AND dim1 > 0
|
||||
AND price > 0
|
||||
),
|
||||
latest AS (
|
||||
SELECT DISTINCT ON (s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0))
|
||||
s.sdprcgrp_id,
|
||||
s.crn,
|
||||
s.dim1,
|
||||
s.dim3,
|
||||
s.prc
|
||||
FROM sdprc s
|
||||
WHERE s.mmitem_id = $2
|
||||
AND (s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0)) IN (
|
||||
SELECT sdprcgrp_id, currency, dim1, COALESCE(dim3, 0) FROM filtered
|
||||
)
|
||||
ORDER BY s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0), s.zlins_dttm DESC, s.id DESC
|
||||
),
|
||||
to_insert AS (
|
||||
SELECT
|
||||
$2::bigint AS mmitem_id,
|
||||
f.sdprcgrp_id,
|
||||
f.currency AS crn,
|
||||
f.dim1,
|
||||
f.dim3,
|
||||
f.price AS prc
|
||||
FROM filtered f
|
||||
LEFT JOIN latest l
|
||||
ON l.sdprcgrp_id = f.sdprcgrp_id
|
||||
AND l.crn = f.currency
|
||||
AND l.dim1 = f.dim1
|
||||
AND ((l.dim3 IS NULL AND f.dim3 IS NULL) OR l.dim3 = f.dim3)
|
||||
WHERE l.prc IS NULL OR l.prc IS DISTINCT FROM f.price
|
||||
),
|
||||
ins AS (
|
||||
INSERT INTO sdprc (mmitem_id, sdprcgrp_id, crn, dim1, dim3, prc, zlins_dttm)
|
||||
SELECT mmitem_id, sdprcgrp_id, crn, dim1, dim3, prc, now()
|
||||
FROM to_insert
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*)::int FROM ins;
|
||||
`
|
||||
var inserted int
|
||||
if err := pgTx.QueryRowContext(ctx, q, raw, mmItemID).Scan(&inserted); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if inserted > 0 {
|
||||
savedPG += inserted
|
||||
changed[productCode] = struct{}{}
|
||||
}
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
// MSSQL memoization: reduce chatter for large batches.
|
||||
// header id cache key: "<CUR>|<PRICEGROUP>"
|
||||
msHeaderIDCache := make(map[string]string, 64)
|
||||
// next sort cache key: "<HEADERID>"
|
||||
msHeaderNextSort := make(map[string]int64, 64)
|
||||
|
||||
type msLatestKey struct {
|
||||
Cur string
|
||||
PriceGroup string
|
||||
}
|
||||
|
||||
loadLatestPricesForProduct := func(productCode string, pairs []msLatestKey) (map[string]float64, map[string]bool) {
|
||||
out := make(map[string]float64, len(pairs))
|
||||
ok := make(map[string]bool, len(pairs))
|
||||
|
||||
productCode = strings.TrimSpace(productCode)
|
||||
if productCode == "" || len(pairs) == 0 {
|
||||
return out, ok
|
||||
}
|
||||
|
||||
conds := make([]string, 0, len(pairs))
|
||||
args := []any{sql.Named("ItemCode", productCode)}
|
||||
for i, p := range pairs {
|
||||
pg := strings.TrimSpace(p.PriceGroup)
|
||||
cur := strings.ToUpper(strings.TrimSpace(p.Cur))
|
||||
if pg == "" || (cur != "USD" && cur != "EUR" && cur != "TRY") {
|
||||
continue
|
||||
}
|
||||
args = append(args,
|
||||
sql.Named(fmt.Sprintf("pg%d", i), pg),
|
||||
sql.Named(fmt.Sprintf("cur%d", i), cur),
|
||||
)
|
||||
conds = append(conds,
|
||||
fmt.Sprintf("(LTRIM(RTRIM(PriceGroupCode)) = @pg%d AND LTRIM(RTRIM(DocCurrencyCode)) = @cur%d)", i, i),
|
||||
)
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
return out, ok
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
SELECT PriceGroupCode, DocCurrencyCode, Price
|
||||
FROM (
|
||||
SELECT
|
||||
LTRIM(RTRIM(PriceGroupCode)) AS PriceGroupCode,
|
||||
LTRIM(RTRIM(DocCurrencyCode)) AS DocCurrencyCode,
|
||||
CAST(Price AS FLOAT) AS Price,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY LTRIM(RTRIM(PriceGroupCode)), LTRIM(RTRIM(DocCurrencyCode))
|
||||
ORDER BY ValidDate DESC, ValidTime DESC, LastUpdatedDate DESC
|
||||
) AS rn
|
||||
FROM dbo.trPriceListLine WITH(NOLOCK)
|
||||
WHERE ItemTypeCode = 1
|
||||
AND LTRIM(RTRIM(ItemCode)) = @ItemCode
|
||||
AND ISNULL(IsDisabled, 0) = 0
|
||||
AND (%s)
|
||||
) x
|
||||
WHERE rn = 1;
|
||||
`, strings.Join(conds, " OR "))
|
||||
|
||||
rows, err := msTx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
logger.Warn("save:mssql:latest:prefetch:error", "product_code", productCode, "err", err)
|
||||
return out, ok
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var pg, cur string
|
||||
var price float64
|
||||
if err := rows.Scan(&pg, &cur, &price); err != nil {
|
||||
logger.Warn("save:mssql:latest:prefetch:scan:error", "product_code", productCode, "err", err)
|
||||
return out, ok
|
||||
}
|
||||
pg = strings.TrimSpace(pg)
|
||||
cur = strings.ToUpper(strings.TrimSpace(cur))
|
||||
k := cur + "|" + pg
|
||||
out[k] = price
|
||||
ok[k] = true
|
||||
}
|
||||
return out, ok
|
||||
}
|
||||
|
||||
// Helper: append-only Nebim price list line (insert new row when price changes).
|
||||
// Resolve PriceListHeaderID from trPriceListHeader (source of truth).
|
||||
// If header does not exist for the given PriceGroupCode+Currency, create it, then insert lines under that header.
|
||||
upsertPriceListLine := func(productCode string, currency string, priceGroup string, price float64, latest map[string]float64, latestOK map[string]bool) (bool, error) {
|
||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
||||
priceGroup = strings.TrimSpace(priceGroup)
|
||||
if price <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
if currency != "USD" && currency != "EUR" && currency != "TRY" {
|
||||
return false, fmt.Errorf("invalid currency")
|
||||
}
|
||||
if priceGroup == "" {
|
||||
return false, fmt.Errorf("empty price group")
|
||||
}
|
||||
|
||||
// Resolve or create header id for that group/currency (memoized).
|
||||
headerKey := currency + "|" + priceGroup
|
||||
headerID := strings.TrimSpace(msHeaderIDCache[headerKey])
|
||||
if headerID == "" {
|
||||
var err error
|
||||
headerID, err = resolveOrCreatePriceListHeaderID(ctx, msTx, priceGroup, currency, claims.Username, logger)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
msHeaderIDCache[headerKey] = headerID
|
||||
}
|
||||
|
||||
// If latest line already has the same price, no-op (prefer prefetch map).
|
||||
if latest != nil && latestOK != nil && latestOK[headerKey] {
|
||||
if curLatest, ok := latest[headerKey]; ok && math.Abs(curLatest-price) < 1e-9 {
|
||||
return false, nil
|
||||
}
|
||||
} else {
|
||||
// Fallback: query latest for this key if not prefetched.
|
||||
var latestPrice sql.NullFloat64
|
||||
_ = msTx.QueryRowContext(ctx, `
|
||||
SELECT TOP (1) CAST(Price AS FLOAT)
|
||||
FROM dbo.trPriceListLine WITH(NOLOCK)
|
||||
WHERE ItemTypeCode = 1
|
||||
AND LTRIM(RTRIM(ItemCode)) = @p1
|
||||
AND LTRIM(RTRIM(DocCurrencyCode)) = @p2
|
||||
AND LTRIM(RTRIM(PriceGroupCode)) = @p3
|
||||
AND ISNULL(IsDisabled, 0) = 0
|
||||
ORDER BY ValidDate DESC, ValidTime DESC, LastUpdatedDate DESC;
|
||||
`, sql.Named("p1", productCode), sql.Named("p2", currency), sql.Named("p3", priceGroup)).Scan(&latestPrice)
|
||||
if latestPrice.Valid && math.Abs(latestPrice.Float64-price) < 1e-9 {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SortOrder: append inside header.
|
||||
nextSort := msHeaderNextSort[headerID]
|
||||
if nextSort <= 0 {
|
||||
_ = msTx.QueryRowContext(ctx, `
|
||||
SELECT ISNULL(MAX(SortOrder), 0) + 1
|
||||
FROM dbo.trPriceListLine WITH(NOLOCK)
|
||||
WHERE PriceListHeaderID = CONVERT(UNIQUEIDENTIFIER, @p1);
|
||||
`, sql.Named("p1", headerID)).Scan(&nextSort)
|
||||
if nextSort <= 0 {
|
||||
nextSort = 1
|
||||
}
|
||||
}
|
||||
msHeaderNextSort[headerID] = nextSort + 1
|
||||
|
||||
// Insert minimal line.
|
||||
_, err := msTx.ExecContext(ctx, `
|
||||
INSERT INTO dbo.trPriceListLine (
|
||||
PriceListLineID,
|
||||
SortOrder,
|
||||
ItemTypeCode,
|
||||
ItemCode,
|
||||
ColorCode,
|
||||
ItemDim1Code,
|
||||
ItemDim2Code,
|
||||
ItemDim3Code,
|
||||
UnitOfMeasureCode,
|
||||
PaymentPlanCode,
|
||||
LineDescription,
|
||||
DocCurrencyCode,
|
||||
Price,
|
||||
IsDisabled,
|
||||
DisableDate,
|
||||
CompanyCode,
|
||||
PriceGroupCode,
|
||||
ValidDate,
|
||||
ValidTime,
|
||||
PriceListHeaderID,
|
||||
CreatedUserName,
|
||||
CreatedDate,
|
||||
LastUpdatedUserName,
|
||||
LastUpdatedDate
|
||||
)
|
||||
VALUES (
|
||||
NEWID(),
|
||||
@SortOrder,
|
||||
1,
|
||||
@ItemCode,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'AD',
|
||||
'',
|
||||
'',
|
||||
@Currency,
|
||||
@Price,
|
||||
0,
|
||||
'1900-01-01',
|
||||
1,
|
||||
@PriceGroupCode,
|
||||
CONVERT(date, GETDATE()),
|
||||
'00:00:00',
|
||||
CONVERT(uniqueidentifier, @HeaderID),
|
||||
@UserName,
|
||||
GETDATE(),
|
||||
@UserName,
|
||||
GETDATE()
|
||||
);
|
||||
`, sql.Named("SortOrder", nextSort),
|
||||
sql.Named("ItemCode", productCode),
|
||||
sql.Named("Currency", currency),
|
||||
sql.Named("Price", price),
|
||||
sql.Named("PriceGroupCode", priceGroup),
|
||||
sql.Named("HeaderID", headerID),
|
||||
sql.Named("UserName", claims.Username),
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
for _, it := range payload.Items {
|
||||
code := strings.TrimSpace(it.ProductCode)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var latestMap map[string]float64
|
||||
var latestOK map[string]bool
|
||||
|
||||
var mmItemID int64
|
||||
if err := pgTx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmItemID); err != nil {
|
||||
// If missing in PG, we can still save MSSQL tiers; PG write will be skipped.
|
||||
mmItemID = 0
|
||||
}
|
||||
dims := []dimCombo{}
|
||||
// Prefer cached dim combos (fast). If not present, load from Nebim stock query (used by product-stock-query UI).
|
||||
if mmItemID > 0 {
|
||||
cacheStarted := time.Now()
|
||||
cached, cacheErr := loadDimCombosFromCache(code)
|
||||
if cacheErr == nil && len(cached) > 0 {
|
||||
dims = cached
|
||||
logger.Info("save:pg:dims:cache:hit",
|
||||
"product_code", code,
|
||||
"dims", len(dims),
|
||||
"duration_ms", time.Since(cacheStarted).Milliseconds(),
|
||||
)
|
||||
} else if cacheErr != nil {
|
||||
logger.Error("save:pg:dims:cache-load:error", "product_code", code, "err", cacheErr)
|
||||
} else {
|
||||
logger.Info("save:pg:dims:cache:miss",
|
||||
"product_code", code,
|
||||
"duration_ms", time.Since(cacheStarted).Milliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
if len(dims) == 0 {
|
||||
d, err := loadDimsFromMssqlStock(code)
|
||||
if err != nil {
|
||||
logger.Error("save:pg:dims:mssql:error", "product_code", code, "err", err)
|
||||
} else {
|
||||
dims = d
|
||||
if err := upsertDimCombosCache(code, dims); err != nil {
|
||||
logger.Error("save:pg:dims:cache:error", "product_code", code, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier prices in PG sdprc + Nebim price list lines (mapped).
|
||||
type tier struct {
|
||||
Cur string
|
||||
Level int
|
||||
Price float64
|
||||
}
|
||||
tiers := []tier{
|
||||
{"USD", 1, it.USD1}, {"USD", 2, it.USD2}, {"USD", 3, it.USD3}, {"USD", 4, it.USD4}, {"USD", 5, it.USD5}, {"USD", 6, it.USD6},
|
||||
{"EUR", 1, it.EUR1}, {"EUR", 2, it.EUR2}, {"EUR", 3, it.EUR3}, {"EUR", 4, it.EUR4}, {"EUR", 5, it.EUR5}, {"EUR", 6, it.EUR6},
|
||||
{"TRY", 1, it.TRY1}, {"TRY", 2, it.TRY2}, {"TRY", 3, it.TRY3}, {"TRY", 4, it.TRY4}, {"TRY", 5, it.TRY5}, {"TRY", 6, it.TRY6},
|
||||
}
|
||||
|
||||
// Prefetch MSSQL latest prices for all relevant pairs for this product.
|
||||
// This turns N tier "TOP 1" lookups into a single query per product.
|
||||
{
|
||||
msPairs := make([]msLatestKey, 0, 24)
|
||||
seen := make(map[string]struct{}, 32)
|
||||
addPair := func(cur, pg string) {
|
||||
cur = strings.ToUpper(strings.TrimSpace(cur))
|
||||
pg = strings.TrimSpace(pg)
|
||||
if pg == "" {
|
||||
return
|
||||
}
|
||||
k := cur + "|" + pg
|
||||
if _, ok := seen[k]; ok {
|
||||
return
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
msPairs = append(msPairs, msLatestKey{Cur: cur, PriceGroup: pg})
|
||||
}
|
||||
if it.BasePriceUsd > 0 {
|
||||
addPair("USD", "TM-USD")
|
||||
}
|
||||
if it.BasePriceTry > 0 {
|
||||
addPair("TRY", "TM-TRY")
|
||||
}
|
||||
for _, t := range tiers {
|
||||
if t.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
nebimGrp := ""
|
||||
if nebimMap[t.Cur] != nil {
|
||||
nebimGrp = nebimMap[t.Cur][t.Level]
|
||||
}
|
||||
if nebimGrp == "" {
|
||||
continue
|
||||
}
|
||||
addPair(t.Cur, nebimGrp)
|
||||
}
|
||||
latestMap, latestOK = loadLatestPricesForProduct(code, msPairs)
|
||||
}
|
||||
|
||||
// Base prices in Nebim price lists.
|
||||
{
|
||||
ch, err := upsertPriceListLine(code, "USD", "TM-USD", it.BasePriceUsd, latestMap, latestOK)
|
||||
if err != nil {
|
||||
logger.Error("save:mssql:base-usd:error", "product_code", code, "err", err)
|
||||
http.Error(w, "mssql base price save error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if ch {
|
||||
changed[code] = struct{}{}
|
||||
savedMSSQL++
|
||||
}
|
||||
|
||||
ch, err = upsertPriceListLine(code, "TRY", "TM-TRY", it.BasePriceTry, latestMap, latestOK)
|
||||
if err != nil {
|
||||
logger.Error("save:mssql:base-try:error", "product_code", code, "err", err)
|
||||
http.Error(w, "mssql base price save error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if ch {
|
||||
changed[code] = struct{}{}
|
||||
savedMSSQL++
|
||||
}
|
||||
}
|
||||
|
||||
// PG write: bulk append-only insert across dims (fast).
|
||||
if mmItemID > 0 && len(dims) > 0 {
|
||||
writeRows := make([]sdprcWriteRow, 0, len(dims)*len(tiers))
|
||||
for _, t := range tiers {
|
||||
if t.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
pgGrp := 0
|
||||
if pgMap[t.Cur] != nil {
|
||||
pgGrp = pgMap[t.Cur][t.Level]
|
||||
}
|
||||
if pgGrp <= 0 {
|
||||
pgGrp = t.Level
|
||||
}
|
||||
for _, dc := range dims {
|
||||
var d3 *int64
|
||||
if dc.Dim3.Valid {
|
||||
v := dc.Dim3.Int64
|
||||
d3 = &v
|
||||
}
|
||||
writeRows = append(writeRows, sdprcWriteRow{
|
||||
Currency: t.Cur,
|
||||
SdprcGrpID: pgGrp,
|
||||
Dim1: dc.Dim1,
|
||||
Dim3: d3,
|
||||
Price: t.Price,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(writeRows) > 0 {
|
||||
startPG := time.Now()
|
||||
inserted, err := bulkAppendOnlyInsertSdprc(mmItemID, code, writeRows)
|
||||
if err != nil {
|
||||
logger.Error("save:pg:sdprc:bulk:error", "product_code", code, "dims", len(dims), "rows", len(writeRows), "err", err)
|
||||
http.Error(w, "postgres tier save error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
logger.Info("save:pg:sdprc:bulk:ok", "product_code", code, "dims", len(dims), "rows", len(writeRows), "inserted", inserted, "duration_ms", time.Since(startPG).Milliseconds())
|
||||
}
|
||||
} else {
|
||||
for _, t := range tiers {
|
||||
if t.Price > 0 {
|
||||
missingPG++
|
||||
logger.Warn("save:pg:sdprc:skip:no-dims", "product_code", code, "currency", t.Cur, "level", t.Level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MSSQL tier writes (mapped).
|
||||
for _, t := range tiers {
|
||||
nebimGrp := ""
|
||||
if nebimMap[t.Cur] != nil {
|
||||
nebimGrp = nebimMap[t.Cur][t.Level]
|
||||
}
|
||||
if nebimGrp == "" {
|
||||
if t.Price > 0 {
|
||||
missingMSSQL++
|
||||
}
|
||||
continue
|
||||
}
|
||||
msChanged, err := upsertPriceListLine(code, t.Cur, nebimGrp, t.Price, latestMap, latestOK)
|
||||
if err != nil {
|
||||
logger.Error("save:mssql:tier:error", "product_code", code, "currency", t.Cur, "level", t.Level, "price_group", nebimGrp, "err", err)
|
||||
http.Error(w, "mssql tier save error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if msChanged {
|
||||
changed[code] = struct{}{}
|
||||
savedMSSQL++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delta queue: only products with an explicit price change record should be processed by delta jobs.
|
||||
{
|
||||
codes := make([]string, 0, len(changed))
|
||||
for c := range changed {
|
||||
codes = append(codes, c)
|
||||
}
|
||||
if _, err := queries.EnqueuePriceRecalc(ctx, pgTx, codes, "manual_price_save"); err != nil {
|
||||
logger.Error("save:enqueue:error", "err", err)
|
||||
http.Error(w, "price recalc enqueue error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := msTx.Commit(); err != nil {
|
||||
logger.Error("save:mssql:commit:error", "err", err)
|
||||
http.Error(w, "mssql commit error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := pgTx.Commit(); err != nil {
|
||||
logger.Error("save:pg:commit:error", "err", err)
|
||||
http.Error(w, "postgres commit error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Post-commit pricing mail: only for actually changed products.
|
||||
if ml != nil && len(changed) > 0 {
|
||||
changedCodes := make([]string, 0, len(changed))
|
||||
for c := range changed {
|
||||
changedCodes = append(changedCodes, c)
|
||||
}
|
||||
actor := claims.Username
|
||||
go sendPricingChangeMails(context.Background(), ml, changedCodes, actor)
|
||||
}
|
||||
|
||||
// Immediate FX delta publish kick (best-effort): run right away for changed products.
|
||||
// Queue entries are still created for reliability; on success we mark them done to avoid a second pass.
|
||||
if len(changed) > 0 {
|
||||
changedCodes := make([]string, 0, len(changed))
|
||||
for c := range changed {
|
||||
changedCodes = append(changedCodes, c)
|
||||
}
|
||||
go func(codes []string) {
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel2()
|
||||
|
||||
written, fxDateYmd, err := queries.PublishDerivedPricesFromAnchor(ctx2, pg, codes, "", false)
|
||||
if err != nil {
|
||||
log.Printf("[PricingFxImmediate] publish_error codes=%d err=%v", len(codes), err)
|
||||
return
|
||||
}
|
||||
tx2, err := pg.BeginTx(ctx2, nil)
|
||||
if err == nil {
|
||||
_, _ = queries.MarkPriceRecalcQueueDoneByProductCodes(ctx2, tx2, codes)
|
||||
_ = tx2.Commit()
|
||||
}
|
||||
log.Printf("[PricingFxImmediate] ok codes=%d sdprc_written=%d fx_date_ymd=%d", len(codes), written, fxDateYmd)
|
||||
}(changedCodes)
|
||||
}
|
||||
|
||||
logger.Info("save:done",
|
||||
"items", len(payload.Items),
|
||||
"saved_pg", savedPG,
|
||||
"saved_mssql", savedMSSQL,
|
||||
"missing_pg", missingPG,
|
||||
"missing_mssql", missingMSSQL,
|
||||
"duration_ms", time.Since(started).Milliseconds(),
|
||||
)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"saved_pg": savedPG,
|
||||
"saved_mssql": savedMSSQL,
|
||||
"missing_pg": missingPG,
|
||||
"missing_mssql": missingMSSQL,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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} }
|
||||
|
||||
158
ui/src/pages/BrandGroupCurrency.vue
Normal file
158
ui/src/pages/BrandGroupCurrency.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="row items-center justify-between q-col-gutter-sm q-mb-md">
|
||||
<div class="col-12 col-md">
|
||||
<div class="text-h6">Marka Grubu Pr Br. Seçimi</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
Marka gruplarının varsayılan çalışma para birimi burada tanımlanır.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-auto row items-center q-gutter-sm">
|
||||
<q-btn
|
||||
color="secondary"
|
||||
outline
|
||||
:loading="loading"
|
||||
label="Yenile"
|
||||
@click="reload"
|
||||
/>
|
||||
<q-btn
|
||||
color="primary"
|
||||
unelevated
|
||||
icon="save"
|
||||
:disable="!canUpdate || dirtyCount === 0 || saving"
|
||||
:loading="saving"
|
||||
:label="`Kaydet (${dirtyCount})`"
|
||||
@click="saveRows"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
flat
|
||||
bordered
|
||||
row-key="id"
|
||||
:rows="rows"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 0 }"
|
||||
hide-bottom
|
||||
class="bg-white"
|
||||
>
|
||||
<template #body-cell-anchor_mode="props">
|
||||
<q-td :props="props">
|
||||
<q-select
|
||||
v-model="props.row._anchor_mode"
|
||||
dense
|
||||
outlined
|
||||
emit-value
|
||||
map-options
|
||||
:options="anchorOptions"
|
||||
:disable="!canUpdate || saving"
|
||||
style="min-width: 120px"
|
||||
@update:model-value="() => markDirty(props.row)"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-status="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
<q-badge v-if="props.row._dirty" color="orange-7">Degisti</q-badge>
|
||||
<q-badge v-else color="grey-6">Kayitli</q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import api from 'src/services/api'
|
||||
import { usePermissionStore } from 'stores/permissionStore'
|
||||
|
||||
const perm = usePermissionStore()
|
||||
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const rows = ref([])
|
||||
|
||||
const anchorOptions = [
|
||||
{ label: 'USD', value: 'USD' },
|
||||
{ label: 'TRY', value: 'TRY' }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ name: 'code', label: 'Kod', field: 'code', align: 'left', sortable: true },
|
||||
{ name: 'title', label: 'Marka Grubu', field: 'title', align: 'left', sortable: true },
|
||||
{ name: 'description', label: 'Açıklama', field: 'description', align: 'left', sortable: true },
|
||||
{ name: 'anchor_mode', label: 'Varsayılan Pr.Br.', field: 'anchor_mode', align: 'left', sortable: true },
|
||||
{ name: 'status', label: '', field: 'status', align: 'right' }
|
||||
]
|
||||
|
||||
const dirtyCount = computed(() => rows.value.filter(row => row?._dirty).length)
|
||||
|
||||
function normalizeRow (row) {
|
||||
const mode = String(row?.anchor_mode || 'USD').trim().toUpperCase() || 'USD'
|
||||
return {
|
||||
id: Number(row?.id || 0),
|
||||
code: String(row?.code || '').trim(),
|
||||
title: String(row?.title || '').trim(),
|
||||
description: String(row?.description || '').trim(),
|
||||
anchor_mode: mode,
|
||||
_anchor_mode: mode,
|
||||
_dirty: false
|
||||
}
|
||||
}
|
||||
|
||||
function markDirty (row) {
|
||||
row._dirty = String(row._anchor_mode || 'USD').trim().toUpperCase() !== String(row.anchor_mode || 'USD').trim().toUpperCase()
|
||||
}
|
||||
|
||||
async function reload () {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request({
|
||||
method: 'GET',
|
||||
url: '/pricing/brand-group-currency',
|
||||
timeout: 180000
|
||||
})
|
||||
rows.value = (Array.isArray(res?.data) ? res.data : []).map(normalizeRow)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Marka grubu para birimi listesi alinamadi' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRows () {
|
||||
const dirty = rows.value.filter(row => row?._dirty)
|
||||
if (dirty.length === 0) return
|
||||
saving.value = true
|
||||
try {
|
||||
await api.request({
|
||||
method: 'POST',
|
||||
url: '/pricing/brand-group-currency/bulk-save',
|
||||
data: {
|
||||
items: dirty.map(row => ({
|
||||
id: row.id,
|
||||
anchor_mode: String(row._anchor_mode || 'USD').trim().toUpperCase()
|
||||
}))
|
||||
},
|
||||
timeout: 180000
|
||||
})
|
||||
for (const row of dirty) {
|
||||
row.anchor_mode = String(row._anchor_mode || 'USD').trim().toUpperCase()
|
||||
row._dirty = false
|
||||
}
|
||||
Notify.create({ type: 'positive', message: `Kaydedildi: ${dirty.length} satir` })
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Marka grubu para birimi kaydedilemedi' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
</script>
|
||||
@@ -86,6 +86,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-banner
|
||||
v-if="csvImportStatus"
|
||||
dense
|
||||
class="q-mb-xs"
|
||||
:class="csvImportStatus.type === 'warning' ? 'bg-amber-2 text-amber-10' : 'bg-green-1 text-green-10'"
|
||||
>
|
||||
{{ csvImportStatus.message }}
|
||||
</q-banner>
|
||||
|
||||
<div class="table-wrap" :style="{ '--sticky-scroll-comp': `${stickyScrollComp}px` }">
|
||||
<q-table
|
||||
flat
|
||||
@@ -272,6 +281,34 @@
|
||||
dense
|
||||
@update:model-value="() => markDirty(props.row)"
|
||||
/>
|
||||
<q-toggle
|
||||
v-else-if="col.name === 'calc_enabled'"
|
||||
v-model="props.row.calc_enabled"
|
||||
dense
|
||||
@update:model-value="() => markDirty(props.row)"
|
||||
/>
|
||||
<q-toggle
|
||||
v-else-if="col.name === 'publish_postgres'"
|
||||
v-model="props.row.publish_postgres"
|
||||
dense
|
||||
@update:model-value="() => markDirty(props.row)"
|
||||
/>
|
||||
<q-toggle
|
||||
v-else-if="col.name === 'publish_nebim'"
|
||||
v-model="props.row.publish_nebim"
|
||||
dense
|
||||
@update:model-value="() => markDirty(props.row)"
|
||||
/>
|
||||
<q-select
|
||||
v-else-if="retailModeFields.has(col.name)"
|
||||
dense
|
||||
outlined
|
||||
emit-value
|
||||
map-options
|
||||
:options="retailModeOptions.map(value => ({ label: value, value }))"
|
||||
:model-value="props.row[col.field]"
|
||||
@update:model-value="(value) => updateRetailMode(props.row, col.field, value)"
|
||||
/>
|
||||
<input
|
||||
v-else-if="numericFields.has(col.name)"
|
||||
class="native-cell-input text-right"
|
||||
@@ -287,6 +324,8 @@
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
|
||||
<q-inner-loading :showing="saving" label="Kaydediliyor..." />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
@@ -306,6 +345,7 @@ const fileInputRef = ref(null)
|
||||
const selectedKeyMap = ref({})
|
||||
const copySelectedKeys = ref([])
|
||||
const tablePagination = ref({ rowsPerPage: 0, sortBy: 'urun_ilk_grubu', descending: false })
|
||||
const csvImportStatus = ref(null) // { type: 'positive'|'warning', message: string, at: string }
|
||||
let emptyRetryTimer = null
|
||||
|
||||
const numericFields = new Set([
|
||||
@@ -313,6 +353,8 @@ const numericFields = new Set([
|
||||
'usd_base', 'usd1', 'usd2', 'usd3', 'usd4', 'usd5', 'usd6', 'usd_wholesale_step', 'usd_retail_step',
|
||||
'eur_base', 'eur1', 'eur2', 'eur3', 'eur4', 'eur5', 'eur6', 'eur_wholesale_step', 'eur_retail_step'
|
||||
])
|
||||
const retailModeFields = new Set(['try_retail_mode', 'usd_retail_mode', 'eur_retail_mode'])
|
||||
const retailModeOptions = ['STEP', 'END_99', 'END_49', 'BAND_99', 'BAND_49']
|
||||
|
||||
const importKeyFieldLabels = [
|
||||
['askili_yan', 'ASKILI YAN'],
|
||||
@@ -328,7 +370,12 @@ const importKeyFieldLabels = [
|
||||
|
||||
const importFieldMap = {
|
||||
AKTIF: 'is_active',
|
||||
'HESAP AKTIF': 'calc_enabled',
|
||||
'PG YAYIN': 'publish_postgres',
|
||||
'NEBIM YAYIN': 'publish_nebim',
|
||||
'TRY TOPTAN YUVARLAMA': 'try_wholesale_step',
|
||||
'TRY PERAKENDE MODU': 'try_retail_mode',
|
||||
'TRY PERAKENDE DEGERI': 'try_retail_step',
|
||||
'TRY PERAKENDE YUVARLAMA': 'try_retail_step',
|
||||
'TRY YUVARLAMA': 'try_wholesale_step',
|
||||
'TRY TABAN': 'try_base',
|
||||
@@ -339,6 +386,8 @@ const importFieldMap = {
|
||||
'TRY 5': 'try5',
|
||||
'TRY 6': 'try6',
|
||||
'USD TOPTAN YUVARLAMA': 'usd_wholesale_step',
|
||||
'USD PERAKENDE MODU': 'usd_retail_mode',
|
||||
'USD PERAKENDE DEGERI': 'usd_retail_step',
|
||||
'USD PERAKENDE YUVARLAMA': 'usd_retail_step',
|
||||
'USD YUVARLAMA': 'usd_wholesale_step',
|
||||
'USD TABAN': 'usd_base',
|
||||
@@ -349,6 +398,8 @@ const importFieldMap = {
|
||||
'USD 5': 'usd5',
|
||||
'USD 6': 'usd6',
|
||||
'EUR TOPTAN YUVARLAMA': 'eur_wholesale_step',
|
||||
'EUR PERAKENDE MODU': 'eur_retail_mode',
|
||||
'EUR PERAKENDE DEGERI': 'eur_retail_step',
|
||||
'EUR PERAKENDE YUVARLAMA': 'eur_retail_step',
|
||||
'EUR YUVARLAMA': 'eur_wholesale_step',
|
||||
'EUR TABAN': 'eur_base',
|
||||
@@ -362,7 +413,8 @@ const importFieldMap = {
|
||||
|
||||
const multiFilterFields = [
|
||||
'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu', 'urun_ana_grubu',
|
||||
'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group'
|
||||
'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group',
|
||||
'try_retail_mode', 'usd_retail_mode', 'eur_retail_mode'
|
||||
]
|
||||
const multiSelectFilterFieldSet = new Set(multiFilterFields)
|
||||
const numberRangeFilterFieldSet = new Set(numericFields)
|
||||
@@ -387,56 +439,64 @@ function col (name, label, field, width, extra = {}) {
|
||||
}
|
||||
|
||||
const columns = [
|
||||
col('copy_select', 'KOPYA', 'copy_select', 86, { sortable: false, classes: 'copy-selection-col', headerClasses: 'copy-selection-col' }),
|
||||
col('select', 'KAYDET', 'select', 72, { sortable: false, classes: 'save-selection-col', headerClasses: 'save-selection-col' }),
|
||||
col('has_rule', 'DURUM', 'has_rule', 62, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('is_active', 'AKTIF', 'is_active', 48, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('askili_yan', 'ASKILI YAN', 'askili_yan', 86, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('kategori', 'KATEGORI', 'kategori', 92, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('urun_ilk_grubu', 'URUN ILK GRUBU', 'urun_ilk_grubu', 100, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('urun_ana_grubu', 'URUN ANA GRUBU', 'urun_ana_grubu', 110, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('urun_alt_grubu', 'URUN ALT GRUBU', 'urun_alt_grubu', 110, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('icerik', 'ICERIK', 'icerik', 90, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('marka', 'MARKA', 'marka', 100, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('brand_code', 'BRAND CODE', 'brand_code', 78, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('brand_group', 'MARKA GRUBU', 'brand_group', 88, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('copy_select', 'KOPYA', 'copy_select', 68, { sortable: false, classes: 'copy-selection-col', headerClasses: 'copy-selection-col' }),
|
||||
col('select', 'KAYDET', 'select', 58, { sortable: false, classes: 'save-selection-col', headerClasses: 'save-selection-col' }),
|
||||
col('has_rule', 'DURUM', 'has_rule', 54, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('is_active', 'AKTIF', 'is_active', 42, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('askili_yan', 'ASKILI YAN', 'askili_yan', 72, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('kategori', 'KATEGORI', 'kategori', 76, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('urun_ilk_grubu', 'URUN ILK GRUBU', 'urun_ilk_grubu', 84, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('urun_ana_grubu', 'URUN ANA GRUBU', 'urun_ana_grubu', 90, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('urun_alt_grubu', 'URUN ALT GRUBU', 'urun_alt_grubu', 90, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('icerik', 'ICERIK', 'icerik', 72, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('marka', 'MARKA', 'marka', 80, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('brand_code', 'BRAND CODE', 'brand_code', 68, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('brand_group', 'MARKA GRUBU', 'brand_group', 76, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('anchor_mode', 'ANCHOR MODE', 'anchor_mode', 66, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('calc_enabled', 'HESAP AKTIF', 'calc_enabled', 66, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('publish_postgres', 'PG YAYIN', 'publish_postgres', 62, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('publish_nebim', 'NEBIM YAYIN', 'publish_nebim', 66, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
|
||||
col('try_wholesale_step', 'TRY TOPTAN YUVARLAMA', 'try_wholesale_step', 92, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try_retail_step', 'TRY PERAKENDE YUVARLAMA', 'try_retail_step', 98, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try_base', 'TRY TABAN', 'try_base', 70, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try1', 'TRY 1', 'try1', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try2', 'TRY 2', 'try2', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try3', 'TRY 3', 'try3', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try4', 'TRY 4', 'try4', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try5', 'TRY 5', 'try5', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try6', 'TRY 6', 'try6', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try_wholesale_step', 'TRY TOPTAN YUVARLAMA', 'try_wholesale_step', 76, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try_retail_mode', 'TRY PERAKENDE MODU', 'try_retail_mode', 76, { classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try_retail_step', 'TRY PERAKENDE DEGERI', 'try_retail_step', 78, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try_base', 'TRY TABAN', 'try_base', 58, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try1', 'TRY 1', 'try1', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try2', 'TRY 2', 'try2', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try3', 'TRY 3', 'try3', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try4', 'TRY 4', 'try4', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try5', 'TRY 5', 'try5', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try6', 'TRY 6', 'try6', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
|
||||
col('usd_wholesale_step', 'USD TOPTAN YUVARLAMA', 'usd_wholesale_step', 92, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd_retail_step', 'USD PERAKENDE YUVARLAMA', 'usd_retail_step', 98, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd_base', 'USD TABAN', 'usd_base', 70, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd1', 'USD 1', 'usd1', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd2', 'USD 2', 'usd2', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd3', 'USD 3', 'usd3', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd4', 'USD 4', 'usd4', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd5', 'USD 5', 'usd5', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd6', 'USD 6', 'usd6', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd_wholesale_step', 'USD TOPTAN YUVARLAMA', 'usd_wholesale_step', 76, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd_retail_mode', 'USD PERAKENDE MODU', 'usd_retail_mode', 76, { classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd_retail_step', 'USD PERAKENDE DEGERI', 'usd_retail_step', 78, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd_base', 'USD TABAN', 'usd_base', 58, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd1', 'USD 1', 'usd1', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd2', 'USD 2', 'usd2', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd3', 'USD 3', 'usd3', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd4', 'USD 4', 'usd4', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd5', 'USD 5', 'usd5', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd6', 'USD 6', 'usd6', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
|
||||
col('eur_wholesale_step', 'EUR TOPTAN YUVARLAMA', 'eur_wholesale_step', 92, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur_retail_step', 'EUR PERAKENDE YUVARLAMA', 'eur_retail_step', 98, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur_base', 'EUR TABAN', 'eur_base', 70, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur1', 'EUR 1', 'eur1', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur2', 'EUR 2', 'eur2', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur3', 'EUR 3', 'eur3', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur4', 'EUR 4', 'eur4', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur5', 'EUR 5', 'eur5', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur6', 'EUR 6', 'eur6', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' })
|
||||
col('eur_wholesale_step', 'EUR TOPTAN YUVARLAMA', 'eur_wholesale_step', 76, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur_retail_mode', 'EUR PERAKENDE MODU', 'eur_retail_mode', 76, { classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur_retail_step', 'EUR PERAKENDE DEGERI', 'eur_retail_step', 78, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur_base', 'EUR TABAN', 'eur_base', 58, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur1', 'EUR 1', 'eur1', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur2', 'EUR 2', 'eur2', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur3', 'EUR 3', 'eur3', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur4', 'EUR 4', 'eur4', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur5', 'EUR 5', 'eur5', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur6', 'EUR 6', 'eur6', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' })
|
||||
]
|
||||
|
||||
const stickyColumnNames = [
|
||||
'copy_select', 'select', 'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu',
|
||||
'urun_ana_grubu', 'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group'
|
||||
'urun_ana_grubu', 'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group',
|
||||
'anchor_mode', 'calc_enabled', 'publish_postgres', 'publish_nebim'
|
||||
]
|
||||
const stickyBoundaryColumnName = 'brand_group'
|
||||
const stickyBoundaryColumnName = 'publish_nebim'
|
||||
const stickyColumnNameSet = new Set(stickyColumnNames)
|
||||
|
||||
const stickyLeftMap = computed(() => {
|
||||
@@ -466,6 +526,7 @@ const tableStyle = computed(() => ({
|
||||
function filterDisplayValue (row, field) {
|
||||
if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni'
|
||||
if (field === 'is_active') return row?.is_active ? 'Aktif' : 'Pasif'
|
||||
if (retailModeFields.has(field)) return String(row?.[field] || 'STEP').trim() || 'STEP'
|
||||
return String(row?.[field] ?? '').trim()
|
||||
}
|
||||
|
||||
@@ -562,6 +623,10 @@ function normalizeWorksheetRow (source) {
|
||||
_row_key: String(source?.scope_key || source?.pricing_parameter_id || ''),
|
||||
has_rule: Boolean(source?.has_rule),
|
||||
id: String(rule?.id || ''),
|
||||
anchor_mode: String(rule?.anchor_mode || 'USD'),
|
||||
calc_enabled: rule?.calc_enabled !== false,
|
||||
publish_postgres: rule?.publish_postgres !== false,
|
||||
publish_nebim: rule?.publish_nebim !== false,
|
||||
is_active: rule?.is_active !== false,
|
||||
askili_yan: String(source?.askili_yan || ''),
|
||||
kategori: String(source?.kategori || ''),
|
||||
@@ -572,6 +637,9 @@ function normalizeWorksheetRow (source) {
|
||||
marka: String(source?.marka || ''),
|
||||
brand_code: String(source?.brand_code || ''),
|
||||
brand_group: String(source?.brand_group || ''),
|
||||
try_retail_mode: String(rule?.try_retail_mode || 'STEP'),
|
||||
usd_retail_mode: String(rule?.usd_retail_mode || 'STEP'),
|
||||
eur_retail_mode: String(rule?.eur_retail_mode || 'STEP'),
|
||||
_dirty: false
|
||||
}
|
||||
for (const key of numericFields) {
|
||||
@@ -607,6 +675,11 @@ function isRowSelected (row) {
|
||||
return !!selectedKeyMap.value?.[row._row_key]
|
||||
}
|
||||
|
||||
function clearSelections () {
|
||||
selectedKeyMap.value = {}
|
||||
copySelectedKeys.value = []
|
||||
}
|
||||
|
||||
function isCopySelected (row) {
|
||||
return copySelectedKeySet.value.has(row._row_key)
|
||||
}
|
||||
@@ -668,9 +741,17 @@ function updateNumber (row, field, value) {
|
||||
markDirty(row)
|
||||
}
|
||||
|
||||
function updateRetailMode (row, field, value) {
|
||||
row[field] = retailModeOptions.includes(String(value || '').trim()) ? String(value).trim() : 'STEP'
|
||||
markDirty(row)
|
||||
}
|
||||
|
||||
function exportSortValue (row, field) {
|
||||
if (field === 'has_rule') return row?.has_rule ? 1 : 0
|
||||
if (field === 'is_active') return row?.is_active ? 1 : 0
|
||||
if (field === 'calc_enabled') return row?.calc_enabled ? 1 : 0
|
||||
if (field === 'publish_postgres') return row?.publish_postgres ? 1 : 0
|
||||
if (field === 'publish_nebim') return row?.publish_nebim ? 1 : 0
|
||||
if (numericFields.has(field)) return finiteNumber(row?.[field], 0)
|
||||
return String(row?.[field] ?? '')
|
||||
}
|
||||
@@ -678,6 +759,16 @@ function exportSortValue (row, field) {
|
||||
function exportCellValue (row, field) {
|
||||
if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni'
|
||||
if (field === 'is_active') return row?.is_active ? 'Aktif' : 'Pasif'
|
||||
if (field === 'calc_enabled') return row?.calc_enabled ? 'Aktif' : 'Pasif'
|
||||
if (field === 'publish_postgres') return row?.publish_postgres ? 'Evet' : 'Hayir'
|
||||
if (field === 'publish_nebim') return row?.publish_nebim ? 'Evet' : 'Hayir'
|
||||
// Excel often coerces numeric-looking codes/names; wrap to keep as text when opened/edited in Excel.
|
||||
if (field === 'brand_code' || field === 'marka') {
|
||||
const text = String(row?.[field] ?? '').trim()
|
||||
if (!text) return ''
|
||||
return `="${text.replaceAll('"', '""')}"`
|
||||
}
|
||||
if (retailModeFields.has(field)) return String(row?.[field] || 'STEP').trim() || 'STEP'
|
||||
if (numericFields.has(field)) {
|
||||
const value = row?.[field]
|
||||
if (value === '' || value === null || value === undefined) return '0'
|
||||
@@ -858,10 +949,17 @@ async function onImportFileChange (event) {
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
console.info('[pricing-rules][ui] csv-import:start', {
|
||||
at: new Date(startedAt).toISOString(),
|
||||
name: file?.name || '',
|
||||
size: file?.size || 0
|
||||
})
|
||||
const text = await file.text()
|
||||
const matrix = parseCsvRows(text).filter(row => row.some(cell => String(cell || '').trim() !== ''))
|
||||
if (matrix.length < 2) {
|
||||
Notify.create({ type: 'negative', message: 'CSV bos veya gecersiz' })
|
||||
csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: 'CSV bos veya gecersiz.' }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -883,6 +981,7 @@ async function onImportFileChange (event) {
|
||||
let matched = 0
|
||||
let updated = 0
|
||||
let skipped = 0
|
||||
const updatedRowKeys = []
|
||||
|
||||
for (let i = 1; i < matrix.length; i++) {
|
||||
const csvRow = matrix[i]
|
||||
@@ -912,7 +1011,22 @@ async function onImportFileChange (event) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (field === 'calc_enabled' || field === 'publish_postgres' || field === 'publish_nebim') {
|
||||
const next = parseImportedBoolean(rawValue)
|
||||
if (next !== null && Boolean(target[field]) !== next) {
|
||||
target[field] = next
|
||||
rowChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (retailModeFields.has(field)) {
|
||||
const next = retailModeOptions.includes(normalizeImportText(rawValue)) ? normalizeImportText(rawValue) : 'STEP'
|
||||
if (String(target[field] || 'STEP') !== next) {
|
||||
target[field] = next
|
||||
rowChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
const next = parseImportedNumber(rawValue)
|
||||
if (Number(target[field] ?? 0) !== next) {
|
||||
target[field] = next
|
||||
@@ -922,21 +1036,48 @@ async function onImportFileChange (event) {
|
||||
|
||||
if (rowChanged) {
|
||||
markDirty(target)
|
||||
updatedRowKeys.push(String(target._row_key || '').trim())
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
if (matched === 0) {
|
||||
Notify.create({ type: 'warning', message: 'CSV icindeki satirlar ekrandaki kayitlarla eslesmedi' })
|
||||
csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: 'CSV icindeki satirlar ekrandaki kayitlarla eslesmedi.' }
|
||||
return
|
||||
}
|
||||
|
||||
Notify.create({
|
||||
type: 'positive',
|
||||
message: `CSV yuklendi. Islenen: ${matrix.length - 1}, eslesen: ${matched}, guncellenen: ${updated}, atlanan: ${skipped}`
|
||||
// Ensure: CSV'den degisen satirlar hem dirty hem de "Kaydet" secimi (checkbox) olarak isaretlensin.
|
||||
// Bazı render edge-case'lerinde sadece sayac artip checkbox guncellenmiyor gibi gorunebiliyor;
|
||||
// burada selection map'i explicit guncelleyip senkronu garanti ediyoruz.
|
||||
if (updatedRowKeys.length > 0) {
|
||||
const next = { ...(selectedKeyMap.value || {}) }
|
||||
for (const key of updatedRowKeys) {
|
||||
if (!key) continue
|
||||
next[key] = true
|
||||
}
|
||||
selectedKeyMap.value = next
|
||||
}
|
||||
|
||||
const summary = `CSV yuklendi. Islenen: ${matrix.length - 1}, eslesen: ${matched}, guncellenen: ${updated}, atlanan: ${skipped}`
|
||||
if (updated === 0) {
|
||||
Notify.create({ type: 'warning', message: summary })
|
||||
csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: summary }
|
||||
} else {
|
||||
Notify.create({ type: 'positive', message: summary })
|
||||
csvImportStatus.value = { type: 'positive', at: new Date().toISOString(), message: summary }
|
||||
}
|
||||
|
||||
console.info('[pricing-rules][ui] csv-import:done', {
|
||||
duration_ms: Date.now() - startedAt,
|
||||
processed: matrix.length - 1,
|
||||
matched,
|
||||
updated,
|
||||
skipped
|
||||
})
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.message || 'CSV okunamadi' })
|
||||
csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: err?.message || 'CSV okunamadi' }
|
||||
} finally {
|
||||
if (input) input.value = ''
|
||||
}
|
||||
@@ -953,6 +1094,12 @@ function copySelectedToSelected () {
|
||||
const target = rows.value.find(row => row._row_key === keys[i])
|
||||
if (!target) continue
|
||||
target.is_active = Boolean(source.is_active)
|
||||
target.calc_enabled = Boolean(source.calc_enabled)
|
||||
target.publish_postgres = Boolean(source.publish_postgres)
|
||||
target.publish_nebim = Boolean(source.publish_nebim)
|
||||
target.try_retail_mode = String(source.try_retail_mode || 'STEP')
|
||||
target.usd_retail_mode = String(source.usd_retail_mode || 'STEP')
|
||||
target.eur_retail_mode = String(source.eur_retail_mode || 'STEP')
|
||||
for (const field of numericFields) {
|
||||
target[field] = source[field]
|
||||
}
|
||||
@@ -1056,8 +1203,7 @@ async function refreshRows () {
|
||||
}
|
||||
|
||||
clearAllFilters()
|
||||
selectedKeyMap.value = {}
|
||||
copySelectedKeys.value = []
|
||||
clearSelections()
|
||||
await loadRows()
|
||||
}
|
||||
|
||||
@@ -1067,6 +1213,7 @@ async function loadRows () {
|
||||
emptyRetryTimer = null
|
||||
}
|
||||
loading.value = true
|
||||
let ok = false
|
||||
try {
|
||||
const res = await api.request({
|
||||
method: 'GET',
|
||||
@@ -1074,16 +1221,17 @@ async function loadRows () {
|
||||
timeout: 180000
|
||||
})
|
||||
rows.value = (Array.isArray(res?.data) ? res.data : []).map(normalizeWorksheetRow)
|
||||
selectedKeyMap.value = {}
|
||||
copySelectedKeys.value = []
|
||||
clearSelections()
|
||||
if (rows.value.length === 0) {
|
||||
emptyRetryTimer = setTimeout(loadRows, 10000)
|
||||
}
|
||||
ok = true
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kural kombinasyonlari alinamadi' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
async function saveSelected () {
|
||||
@@ -1091,27 +1239,124 @@ async function saveSelected () {
|
||||
if (dirty.length === 0) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
items: dirty.map(row => {
|
||||
const startedAt = Date.now()
|
||||
console.info('[pricing-rules][ui] saveSelected:start', {
|
||||
at: new Date(startedAt).toISOString(),
|
||||
dirty_count: dirty.length
|
||||
})
|
||||
|
||||
const buildPayload = (list) => {
|
||||
return {
|
||||
items: list.map(row => {
|
||||
const item = {
|
||||
id: row.id,
|
||||
pricing_parameter_id: row.pricing_parameter_id,
|
||||
is_active: Boolean(row.is_active)
|
||||
calc_enabled: Boolean(row.calc_enabled),
|
||||
publish_postgres: Boolean(row.publish_postgres),
|
||||
publish_nebim: Boolean(row.publish_nebim),
|
||||
is_active: Boolean(row.is_active),
|
||||
try_retail_mode: String(row.try_retail_mode || 'STEP'),
|
||||
usd_retail_mode: String(row.usd_retail_mode || 'STEP'),
|
||||
eur_retail_mode: String(row.eur_retail_mode || 'STEP')
|
||||
}
|
||||
for (const key of numericFields) item[key] = finiteNumber(row[key], 0)
|
||||
return item
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const makeTraceId = () => `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const isTimeoutLikeError = (e) => {
|
||||
const status = e?.response?.status || null
|
||||
// With axios timeout disabled for bulk-save, treat only real upstream/proxy timeouts as retry-able.
|
||||
return status === 504
|
||||
}
|
||||
|
||||
let savedTotal = 0
|
||||
const failedKeys = []
|
||||
|
||||
const postBulkSave = async (list) => {
|
||||
const traceId = makeTraceId()
|
||||
const payload = buildPayload(list)
|
||||
await api.request({
|
||||
method: 'POST',
|
||||
url: '/pricing/pricing-rules/bulk-save',
|
||||
data: payload,
|
||||
timeout: 180000
|
||||
// Disable axios timeout here: backend may legitimately run for several minutes on the first write after a full truncate/import.
|
||||
// Any upstream/proxy timeout will surface as 504 anyway.
|
||||
timeout: 0,
|
||||
headers: { 'X-Trace-ID': traceId }
|
||||
})
|
||||
return traceId
|
||||
}
|
||||
|
||||
// Prefer single request (fast path). Fallback to bisection only on proxy/timeout errors.
|
||||
try {
|
||||
const traceId = await postBulkSave(dirty)
|
||||
savedTotal = dirty.length
|
||||
console.info('[pricing-rules][ui] saveSelected:one-shot:done', { trace_id: traceId, total: dirty.length })
|
||||
} catch (e) {
|
||||
if (!isTimeoutLikeError(e)) throw e
|
||||
|
||||
const initialChunkSize = 50
|
||||
const queue = []
|
||||
for (let offset = 0; offset < dirty.length; offset += initialChunkSize) {
|
||||
queue.push(dirty.slice(offset, offset + initialChunkSize))
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const batch = queue.shift()
|
||||
if (!batch || batch.length === 0) continue
|
||||
|
||||
console.info('[pricing-rules][ui] saveSelected:batch:start', {
|
||||
batch_size: batch.length,
|
||||
saved_total: savedTotal,
|
||||
total: dirty.length
|
||||
})
|
||||
|
||||
try {
|
||||
const traceId = await postBulkSave(batch)
|
||||
savedTotal += batch.length
|
||||
Notify.create({ type: 'positive', message: `Kaydedildi: ${savedTotal} / ${dirty.length}` })
|
||||
console.info('[pricing-rules][ui] saveSelected:batch:done', {
|
||||
trace_id: traceId,
|
||||
batch_size: batch.length,
|
||||
saved_total: savedTotal,
|
||||
total: dirty.length
|
||||
})
|
||||
} catch (err2) {
|
||||
if (isTimeoutLikeError(err2) && batch.length > 1) {
|
||||
const mid = Math.ceil(batch.length / 2)
|
||||
queue.unshift(batch.slice(mid))
|
||||
queue.unshift(batch.slice(0, mid))
|
||||
continue
|
||||
}
|
||||
throw err2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reloaded = await loadRows()
|
||||
if (!reloaded) {
|
||||
Notify.create({
|
||||
type: 'warning',
|
||||
message: 'Kaydetme tamamlandi, ancak liste yenilenemedi. Sayfayi yenileyip (F5) kontrol edin.'
|
||||
})
|
||||
return
|
||||
}
|
||||
Notify.create({ type: 'positive', message: `Kaydedildi: ${dirty.length}` })
|
||||
await loadRows()
|
||||
csvImportStatus.value = null
|
||||
console.info('[pricing-rules][ui] saveSelected:done', {
|
||||
duration_ms: Date.now() - startedAt,
|
||||
dirty_count: dirty.length,
|
||||
reloaded: true
|
||||
})
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kurallar kaydedilemedi' })
|
||||
console.error('[pricing-rules][ui] saveSelected:error', {
|
||||
status: err?.response?.status || null,
|
||||
message: err?.response?.data || err?.message || 'Kurallar kaydedilemedi'
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -1119,8 +1364,7 @@ async function saveSelected () {
|
||||
|
||||
function resetTransientState () {
|
||||
rows.value = []
|
||||
selectedKeyMap.value = {}
|
||||
copySelectedKeys.value = []
|
||||
clearSelections()
|
||||
}
|
||||
|
||||
onMounted(refreshRows)
|
||||
@@ -1133,10 +1377,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.pricing-rules-page {
|
||||
--rules-row-height: 31px;
|
||||
--rules-header-height: 72px;
|
||||
--rules-row-height: 27px;
|
||||
--rules-header-height: 58px;
|
||||
--rules-table-height: calc(100vh - 210px);
|
||||
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
@@ -1181,7 +1426,7 @@ onBeforeUnmount(() => {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
table-layout: fixed;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin-right: var(--sticky-scroll-comp, 0px);
|
||||
@@ -1197,7 +1442,7 @@ onBeforeUnmount(() => {
|
||||
.rules-table :deep(th),
|
||||
.rules-table :deep(td) {
|
||||
box-sizing: border-box;
|
||||
padding: 0 4px;
|
||||
padding: 0 2px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -1229,7 +1474,7 @@ onBeforeUnmount(() => {
|
||||
word-break: normal;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
}
|
||||
@@ -1301,7 +1546,7 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
color: #bf5b04;
|
||||
}
|
||||
@@ -1375,7 +1620,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.rules-table :deep(.selection-col .q-checkbox__inner) {
|
||||
color: var(--q-primary);
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.copy-cell-wrap {
|
||||
@@ -1389,7 +1634,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.rules-table :deep(.rule-select-checkbox .q-checkbox__inner) {
|
||||
font-size: 24px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.rules-table :deep(th.usd-col),
|
||||
@@ -1419,18 +1664,19 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.1;
|
||||
padding: 0 4px;
|
||||
padding: 0 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.native-cell-input {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
padding: 1px 3px;
|
||||
padding: 1px 2px;
|
||||
border: 1px solid #cfd8dc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1440,7 +1686,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.action-legend :deep(.q-chip) {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="top-bar row items-center justify-between q-mb-xs">
|
||||
<div class="text-subtitle1 text-weight-bold">Urun Fiyatlandirma</div>
|
||||
<div class="top-actions">
|
||||
<div class="row items-center q-gutter-xs top-actions-row">
|
||||
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--filters">
|
||||
<q-select
|
||||
v-model="topUrunIlkGrubu"
|
||||
dense
|
||||
@@ -51,8 +51,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row items-center q-gutter-xs top-actions-row">
|
||||
<q-btn-dropdown color="secondary" outline icon="view_module" label="Doviz Gorunumu" :auto-close="false">
|
||||
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--actions">
|
||||
<div class="toolbar-group">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
color="grey-8"
|
||||
icon="view_sidebar"
|
||||
:label="leftDetailsExpanded ? 'Detaylari Gizle' : 'Detaylari Goster'"
|
||||
@click="leftDetailsExpanded = !leftDetailsExpanded"
|
||||
/>
|
||||
<q-btn-dropdown dense color="secondary" outline icon="view_module" label="Doviz Gorunumu" :auto-close="false">
|
||||
<q-list dense class="currency-menu-list">
|
||||
<q-item clickable @click="selectAllCurrencies">
|
||||
<q-item-section>Tumunu Sec</q-item-section>
|
||||
@@ -74,7 +83,11 @@
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
:color="showSelectedOnly ? 'primary' : 'grey-7'"
|
||||
:icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'"
|
||||
@@ -83,30 +96,49 @@
|
||||
@click="toggleShowSelectedOnly"
|
||||
/>
|
||||
<q-btn
|
||||
dense
|
||||
color="primary"
|
||||
outline
|
||||
icon="edit_note"
|
||||
label="Secili Olanlari Toplu Degistir"
|
||||
:disable="selectedRowCount === 0"
|
||||
@click="bulkDialogOpen = true"
|
||||
icon="calculate"
|
||||
label="Secilileri Hesapla"
|
||||
:disable="selectedRowCount === 0 || bulkCalcLoading"
|
||||
:loading="bulkCalcLoading"
|
||||
@click="calculateSelectedRows"
|
||||
/>
|
||||
<q-btn
|
||||
dense
|
||||
color="primary"
|
||||
flat
|
||||
icon="download"
|
||||
label="Sayfayi Excel'e Aktar"
|
||||
:disable="filteredRows.length === 0"
|
||||
@click="exportCurrentView"
|
||||
/>
|
||||
<q-btn
|
||||
color="primary"
|
||||
outline
|
||||
icon="download_for_offline"
|
||||
label="Tum Filtreyi Excel'e Aktar"
|
||||
:disable="filteredRows.length === 0 || exportAllLoading"
|
||||
:loading="exportAllLoading"
|
||||
@click="exportAllFiltered"
|
||||
icon="save"
|
||||
:label="saveButtonLabel"
|
||||
:disable="selectedDirtyCount === 0 || saving"
|
||||
:loading="saving"
|
||||
@click="saveSelectedRows"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<q-btn-dropdown dense color="primary" outline icon="download" label="Cikti Al" :auto-close="true">
|
||||
<q-list dense style="min-width: 260px;">
|
||||
<q-item clickable :disable="filteredRows.length === 0" @click="exportCurrentView">
|
||||
<q-item-section avatar><q-icon name="grid_on" /></q-item-section>
|
||||
<q-item-section>Sayfayi Excel'e Aktar</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable :disable="filteredRows.length === 0 || exportAllLoading" @click="exportAllFiltered">
|
||||
<q-item-section avatar><q-icon name="download_for_offline" /></q-item-section>
|
||||
<q-item-section>Tum Filtreyi Excel'e Aktar</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item clickable :disable="store.loading" @click="openPriceListExportDialog()">
|
||||
<q-item-section avatar><q-icon name="receipt_long" /></q-item-section>
|
||||
<q-item-section>Fiyat Listesi Ciktisi...</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
|
||||
<q-space />
|
||||
|
||||
<div class="toolbar-group toolbar-group--paging">
|
||||
<q-pagination
|
||||
v-model="currentPage"
|
||||
color="primary"
|
||||
@@ -122,8 +154,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" :style="{ '--sticky-scroll-comp': `${stickyScrollComp}px` }">
|
||||
<q-inner-loading :showing="saving || bulkCalcLoading">
|
||||
<q-spinner-gears size="46px" color="primary" />
|
||||
</q-inner-loading>
|
||||
<div v-if="showGuidanceOverlay" class="empty-overlay">
|
||||
<div class="empty-overlay-inner">
|
||||
<div class="text-subtitle1 text-weight-bold">Calismaya Baslamak Icin</div>
|
||||
<div class="text-body2 q-mt-xs">
|
||||
Urun Ilk Grubu veya Urun Ana Grubu secin ve <b>GRUPLARI GETIR</b>'e basin.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="topScrollRef"
|
||||
class="top-x-scroll"
|
||||
@scroll.passive="onTopScroll"
|
||||
>
|
||||
<div
|
||||
ref="topScrollInnerRef"
|
||||
class="top-x-scroll-inner"
|
||||
:style="{ width: `${tableMinWidth}px` }"
|
||||
/>
|
||||
</div>
|
||||
<q-table
|
||||
ref="mainTableRef"
|
||||
class="pane-table pricing-table"
|
||||
@@ -157,7 +212,15 @@
|
||||
@update:model-value="toggleSelectAllVisible"
|
||||
/>
|
||||
<div v-else class="header-with-filter">
|
||||
<span>{{ col.label }}</span>
|
||||
<span :title="col.label">{{ col.label }}</span>
|
||||
<q-tooltip
|
||||
v-if="col.label"
|
||||
anchor="top middle"
|
||||
self="bottom middle"
|
||||
:offset="[0, 6]"
|
||||
>
|
||||
{{ col.label }}
|
||||
</q-tooltip>
|
||||
<q-btn
|
||||
v-if="isHeaderFilterField(col.field)"
|
||||
dense
|
||||
@@ -350,7 +413,7 @@
|
||||
<q-checkbox
|
||||
size="sm"
|
||||
color="primary"
|
||||
:model-value="isRowSelected(props.row.productCode)"
|
||||
:model-value="isRowSelected(rowSelectionKey(props.row))"
|
||||
@update:model-value="(val) => onRowCheckboxChange(props.row, val)"
|
||||
@click.stop
|
||||
/>
|
||||
@@ -368,15 +431,41 @@
|
||||
size="sm"
|
||||
color="primary"
|
||||
label="Hesapla"
|
||||
:loading="!!calcLoadingMap[props.row.productCode]"
|
||||
@click="calculateRow(props.row)"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-historyAction="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
size="sm"
|
||||
color="grey-8"
|
||||
icon="history"
|
||||
:disable="!props.row?.productCode"
|
||||
@click="openPriceHistoryDialog(props.row)"
|
||||
>
|
||||
<q-tooltip anchor="top middle" self="bottom middle" :offset="[0, 6]">Fiyat gecmisi</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-productCode="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span class="product-code-text" :title="String(props.value ?? '')">{{ props.value }}</span>
|
||||
@@ -386,7 +475,11 @@
|
||||
<template #body-cell-stockQty="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span class="stock-qty-text">{{ formatStock(props.value) }}</span>
|
||||
@@ -396,7 +489,11 @@
|
||||
<template #body-cell-stockEntryDate="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span class="date-cell-text">{{ formatDateDisplay(props.value) }}</span>
|
||||
@@ -408,6 +505,7 @@
|
||||
:props="props"
|
||||
:class="[
|
||||
{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) },
|
||||
{ 'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name) },
|
||||
{ 'cell-danger': needsCosting(props.row) }
|
||||
]"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
@@ -424,7 +522,11 @@
|
||||
<template #body-cell-lastPricingDate="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span :class="['date-cell-text', { 'date-warning': needsRepricing(props.row) }]">
|
||||
@@ -436,7 +538,11 @@
|
||||
<template #body-cell-brandGroupSelection="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span class="cell-text" :title="props.row.brandGroupSelection || ''">
|
||||
@@ -448,75 +554,293 @@
|
||||
<template #body-cell="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<div v-if="editableColumnSet.has(props.col.name)" class="editable-price-cell">
|
||||
<input
|
||||
v-if="editableColumnSet.has(props.col.name)"
|
||||
class="native-cell-input text-right"
|
||||
class="native-cell-input text-right price-edit-input"
|
||||
:value="formatPrice(props.row[props.col.field])"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
@change="(e) => onEditableCellChange(props.row, props.col.field, e.target.value)"
|
||||
/>
|
||||
<span class="old-price-label" :title="`Eski: ${formatPrice(getOriginalCellValue(props.row, props.col.field))}`">
|
||||
{{ formatPrice(getOriginalCellValue(props.row, props.col.field)) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="cell-text" :title="String(props.value ?? '')">{{ props.value }}</span>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
|
||||
<q-banner v-if="store.error" class="bg-red text-white q-mt-xs">
|
||||
Hata: {{ store.error }}
|
||||
<q-banner v-if="store.error && !isGuidanceState" class="bg-red text-white q-mt-xs">
|
||||
{{ store.error }}
|
||||
</q-banner>
|
||||
|
||||
<q-dialog v-model="bulkDialogOpen">
|
||||
<q-card style="min-width: 420px; max-width: 95vw;">
|
||||
<q-card-section class="text-subtitle1 text-weight-bold">
|
||||
Secili Olanlari Toplu Degistir
|
||||
<q-dialog v-model="priceHistoryDialogOpen" persistent>
|
||||
<q-card class="price-history-card">
|
||||
<q-card-section class="row items-center justify-between">
|
||||
<div>
|
||||
<div class="text-subtitle1 text-weight-bold">Urun Fiyat Karti</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
{{ priceHistoryRow?.productCode || '-' }} | {{ priceHistoryRow?.marka || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<q-btn flat round icon="close" color="grey-8" @click="priceHistoryDialogOpen = false" />
|
||||
</q-card-section>
|
||||
<q-card-section class="q-gutter-sm">
|
||||
<q-select
|
||||
v-model="bulkField"
|
||||
:options="bulkFieldOptions"
|
||||
option-value="value"
|
||||
option-label="label"
|
||||
emit-value
|
||||
map-options
|
||||
dense
|
||||
outlined
|
||||
label="Alan"
|
||||
|
||||
<q-separator />
|
||||
|
||||
<q-card-section class="q-pt-sm q-pb-none">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-btn
|
||||
color="negative"
|
||||
icon="delete"
|
||||
label="Secilenleri Sil"
|
||||
:disable="selectedHistoryCount === 0 || priceHistoryLoading"
|
||||
@click="confirmDeleteSelectedHistory"
|
||||
/>
|
||||
<q-input
|
||||
v-model="bulkValue"
|
||||
dense
|
||||
outlined
|
||||
label="Deger"
|
||||
inputmode="decimal"
|
||||
<q-space />
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
icon="refresh"
|
||||
label="Yenile"
|
||||
:loading="priceHistoryLoading"
|
||||
:disable="!priceHistoryRow?.productCode"
|
||||
@click="reloadPriceHistory()"
|
||||
/>
|
||||
<div class="text-caption text-grey-8">
|
||||
Uygulanacak satir sayisi: {{ selectedRowCount }}
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Iptal" v-close-popup />
|
||||
<q-btn color="primary" label="Uygula" @click="applyBulkUpdate" />
|
||||
</q-card-actions>
|
||||
|
||||
<q-card-section class="q-pt-sm">
|
||||
<q-inner-loading :showing="priceHistoryLoading">
|
||||
<q-spinner-gears size="46px" color="primary" />
|
||||
</q-inner-loading>
|
||||
|
||||
<q-tabs v-model="priceHistoryTab" dense inline-label class="text-grey-8" active-color="primary">
|
||||
<q-tab name="pg" icon="storefront" label="B2B/B2C" />
|
||||
<q-tab name="mssql" icon="dns" label="NEBIM_V3" />
|
||||
</q-tabs>
|
||||
<q-separator class="q-mt-xs q-mb-sm" />
|
||||
|
||||
<q-tab-panels v-model="priceHistoryTab" animated>
|
||||
<q-tab-panel name="pg" class="q-pa-none">
|
||||
<div v-if="pgHistoryGroups.length === 0" class="text-caption text-grey-7 q-pa-sm">
|
||||
Kayit bulunamadi.
|
||||
</div>
|
||||
<q-list v-else dense bordered separator>
|
||||
<q-expansion-item
|
||||
v-for="g in pgHistoryGroups"
|
||||
:key="g.key"
|
||||
expand-separator
|
||||
:label="`${g.currency} ${(g.levelNo <= 5) ? 'B2B' : 'B2C'} Level ${g.levelNo}`"
|
||||
:caption="`${g.rows.length} kayit | Son: ${formatMoney(g.latest?.price)} @ ${g.latest?.updated_at || '-'}`"
|
||||
>
|
||||
<q-item v-for="r in g.rows" :key="r.id">
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
:model-value="selectedPgIdSet.has(r.id)"
|
||||
dense
|
||||
@update:model-value="(val) => toggleSelectedPgId(r.id, val)"
|
||||
@click.stop
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ formatMoney(r.price) }}</q-item-label>
|
||||
<q-item-label caption>{{ r.updated_at }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-badge color="grey-6" outline>{{ r.currency }} {{ r.level_no }}</q-badge>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="mssql" class="q-pa-none">
|
||||
<div v-if="mssqlHistoryGroups.length === 0" class="text-caption text-grey-7 q-pa-sm">
|
||||
Kayit bulunamadi.
|
||||
</div>
|
||||
<q-list v-else dense bordered separator>
|
||||
<q-expansion-item
|
||||
v-for="g in mssqlHistoryGroups"
|
||||
:key="g.key"
|
||||
expand-separator
|
||||
:label="`${g.currency} ${g.price_group_code}`"
|
||||
:caption="`${g.rows.length} kayit | Son: ${formatMoney(g.latest?.price)} @ ${formatMssqlStamp(g.latest)}`"
|
||||
>
|
||||
<q-item v-for="r in g.rows" :key="r.price_list_line_id">
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
:model-value="selectedMssqlIdSet.has(r.price_list_line_id)"
|
||||
dense
|
||||
@update:model-value="(val) => toggleSelectedMssqlId(r.price_list_line_id, val)"
|
||||
@click.stop
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ formatMoney(r.price) }}</q-item-label>
|
||||
<q-item-label caption>{{ formatMssqlStamp(r) }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-badge color="grey-6" outline>{{ r.currency }} {{ r.price_group_code }}</q-badge>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="priceListExportDialogOpen" persistent>
|
||||
<q-card style="min-width: 740px; max-width: 95vw;">
|
||||
<q-card-section class="row items-center justify-between">
|
||||
<div class="text-subtitle1 text-weight-bold">
|
||||
Fiyat Listesi Ciktisi
|
||||
</div>
|
||||
<q-btn flat round icon="close" color="grey-8" @click="priceListExportDialogOpen = false" />
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
|
||||
<q-card-section class="q-gutter-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-btn-toggle
|
||||
v-model="priceListExportFormat"
|
||||
dense
|
||||
unelevated
|
||||
toggle-color="primary"
|
||||
color="grey-3"
|
||||
text-color="grey-9"
|
||||
:options="[
|
||||
{ label: 'PDF', value: 'pdf', icon: 'picture_as_pdf' },
|
||||
{ label: 'Excel', value: 'excel', icon: 'grid_on' }
|
||||
]"
|
||||
/>
|
||||
<q-toggle v-model="priceListInStockOnly" label="Sadece stogu olan urunler" />
|
||||
<q-space />
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="download"
|
||||
:label="priceListExportFormat === 'pdf' ? 'PDF Olustur' : 'Excel Olustur'"
|
||||
:loading="priceListExportLoading"
|
||||
@click="runPriceListExport"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
v-model="priceListUrunIlkGrubu"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
emit-value
|
||||
map-options
|
||||
:options="topUrunIlkGrubuOptions"
|
||||
:loading="Boolean(serverFilterLoading.urunIlkGrubu)"
|
||||
label="Urun Ilk Grubu"
|
||||
@filter="onTopFilterSearchUrunIlkGrubu"
|
||||
@update:model-value="onPriceListUrunIlkGrubuChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
v-model="priceListUrunAnaGrubu"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
multiple
|
||||
use-chips
|
||||
emit-value
|
||||
map-options
|
||||
:options="topUrunAnaGrubuOptions"
|
||||
:loading="Boolean(serverFilterLoading.urunAnaGrubu)"
|
||||
label="Urun Ana Grubu (max 3)"
|
||||
@filter="onTopFilterSearchUrunAnaGrubu"
|
||||
@update:model-value="onPriceListUrunAnaGrubuChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
v-model="priceListUrunAltGrubu"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
multiple
|
||||
use-chips
|
||||
emit-value
|
||||
map-options
|
||||
:options="priceListUrunAltGrubuOptions"
|
||||
:loading="Boolean(serverFilterLoading.urunAltGrubu)"
|
||||
label="Urun Alt Grubu"
|
||||
@filter="onPriceListFilterSearchUrunAltGrubu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-toggle v-model="priceListIncludeCost" label="Maliyet fiyati" />
|
||||
<q-toggle v-model="priceListIncludeBase" label="Taban fiyatlar (USD/TRY)" />
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="text-caption text-grey-8 q-mb-xs">USD seviyeleri</div>
|
||||
<q-option-group
|
||||
v-model="priceListUSDLevels"
|
||||
type="checkbox"
|
||||
dense
|
||||
:options="priceLevelOptionsUSD"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="text-caption text-grey-8 q-mb-xs">EUR seviyeleri</div>
|
||||
<q-option-group
|
||||
v-model="priceListEURLevels"
|
||||
type="checkbox"
|
||||
dense
|
||||
:options="priceLevelOptionsEUR"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="text-caption text-grey-8 q-mb-xs">TRY seviyeleri</div>
|
||||
<q-option-group
|
||||
v-model="priceListTRYLevels"
|
||||
type="checkbox"
|
||||
dense
|
||||
:options="priceLevelOptionsTRY"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Notify, useQuasar } from 'quasar'
|
||||
import { useProductPricingStore } from 'src/stores/ProductPricingStore'
|
||||
import api, { download } from 'src/services/api'
|
||||
|
||||
const $q = useQuasar()
|
||||
const store = useProductPricingStore()
|
||||
const PAGE_LIMIT = 250
|
||||
const currentPage = ref(1)
|
||||
let reloadTimer = null
|
||||
|
||||
const GUIDANCE_MSG = "Calismak icin once Urun Ilk Grubu veya Urun Ana Grubu Secin ve GRUPLARI GETIR'e Basin."
|
||||
|
||||
const usdToTry = 38.25
|
||||
const eurToTry = 41.6
|
||||
const multipliers = [1, 1.03, 1.06, 1.09, 1.12, 1.15]
|
||||
@@ -558,7 +882,6 @@ const numberRangeFilterFields = ['stockQty']
|
||||
const dateRangeFilterFields = ['stockEntryDate', 'lastPricingDate']
|
||||
const valueFilterFields = [
|
||||
'costPrice',
|
||||
'expenseForBasePrice',
|
||||
'basePriceUsd',
|
||||
'basePriceTry',
|
||||
'usd1',
|
||||
@@ -615,6 +938,32 @@ const topUrunAnaGrubu = ref([])
|
||||
|
||||
const topUrunIlkGrubuOptions = computed(() => serverFilterOptionMap.value.urunIlkGrubu || [])
|
||||
const topUrunAnaGrubuOptions = computed(() => serverFilterOptionMap.value.urunAnaGrubu || [])
|
||||
const priceListUrunAltGrubuOptions = computed(() => serverFilterOptionMap.value.urunAltGrubu || [])
|
||||
|
||||
const priceLevelOptionsUSD = [
|
||||
{ label: 'USD 1', value: 1 },
|
||||
{ label: 'USD 2', value: 2 },
|
||||
{ label: 'USD 3', value: 3 },
|
||||
{ label: 'USD 4', value: 4 },
|
||||
{ label: 'USD 5', value: 5 },
|
||||
{ label: 'USD 6', value: 6 }
|
||||
]
|
||||
const priceLevelOptionsEUR = [
|
||||
{ label: 'EUR 1', value: 1 },
|
||||
{ label: 'EUR 2', value: 2 },
|
||||
{ label: 'EUR 3', value: 3 },
|
||||
{ label: 'EUR 4', value: 4 },
|
||||
{ label: 'EUR 5', value: 5 },
|
||||
{ label: 'EUR 6', value: 6 }
|
||||
]
|
||||
const priceLevelOptionsTRY = [
|
||||
{ label: 'TRY 1', value: 1 },
|
||||
{ label: 'TRY 2', value: 2 },
|
||||
{ label: 'TRY 3', value: 3 },
|
||||
{ label: 'TRY 4', value: 4 },
|
||||
{ label: 'TRY 5', value: 5 },
|
||||
{ label: 'TRY 6', value: 6 }
|
||||
]
|
||||
const canFetchByGroup = computed(() => {
|
||||
return Boolean(String(topUrunIlkGrubu.value || '').trim()) || (topUrunAnaGrubu.value?.length || 0) > 0
|
||||
})
|
||||
@@ -709,13 +1058,107 @@ function onTopUrunAnaGrubuChange () {
|
||||
applyTopGroupFiltersToColumnFilters()
|
||||
}
|
||||
|
||||
function onPriceListUrunIlkGrubuChange () {
|
||||
// cascade for export dialog
|
||||
priceListUrunAnaGrubu.value = []
|
||||
const ilk = String(priceListUrunIlkGrubu.value || '').trim()
|
||||
if (ilk) {
|
||||
// scope ana grubu options
|
||||
topUrunIlkGrubu.value = ilk
|
||||
void fetchServerFilterOptions('urunAnaGrubu', { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function onPriceListUrunAnaGrubuChange () {
|
||||
// enforce max 3
|
||||
const nextAna = Array.isArray(priceListUrunAnaGrubu.value) ? priceListUrunAnaGrubu.value.slice(0, 3) : []
|
||||
if (nextAna.length !== (priceListUrunAnaGrubu.value || []).length) priceListUrunAnaGrubu.value = nextAna
|
||||
}
|
||||
|
||||
function onPriceListFilterSearchUrunAltGrubu (val, update) {
|
||||
update(() => {
|
||||
columnFilterSearch.value = { ...columnFilterSearch.value, urunAltGrubu: String(val || '') }
|
||||
scheduleServerFilterOptionsFetch('urunAltGrubu')
|
||||
})
|
||||
}
|
||||
|
||||
function openPriceListExportDialog (format) {
|
||||
// format optional (default: pdf); dialog includes its own format selector.
|
||||
if (format === 'excel' || format === 'pdf') {
|
||||
priceListExportFormat.value = format
|
||||
} else {
|
||||
priceListExportFormat.value = 'pdf'
|
||||
}
|
||||
priceListExportLoading.value = false
|
||||
// default selections: mirror top group selections if present
|
||||
priceListUrunIlkGrubu.value = topUrunIlkGrubu.value
|
||||
priceListUrunAnaGrubu.value = Array.isArray(topUrunAnaGrubu.value) ? topUrunAnaGrubu.value.slice(0, 3) : []
|
||||
priceListUrunAltGrubu.value = []
|
||||
// preload alt group options
|
||||
void fetchServerFilterOptions('urunAltGrubu', { force: true })
|
||||
priceListExportDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function runPriceListExport () {
|
||||
priceListExportLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
in_stock_only: !!priceListInStockOnly.value,
|
||||
include_meta: true,
|
||||
include_cost: !!priceListIncludeCost.value,
|
||||
include_base: !!priceListIncludeBase.value,
|
||||
usd_levels: Array.isArray(priceListUSDLevels.value) ? priceListUSDLevels.value : [],
|
||||
eur_levels: Array.isArray(priceListEURLevels.value) ? priceListEURLevels.value : [],
|
||||
try_levels: Array.isArray(priceListTRYLevels.value) ? priceListTRYLevels.value : [],
|
||||
urun_ilk_grubu: String(priceListUrunIlkGrubu.value || '').trim() ? [String(priceListUrunIlkGrubu.value || '').trim()] : [],
|
||||
urun_ana_grubu: Array.isArray(priceListUrunAnaGrubu.value) ? priceListUrunAnaGrubu.value : [],
|
||||
urun_alt_grubu: Array.isArray(priceListUrunAltGrubu.value) ? priceListUrunAltGrubu.value : []
|
||||
}
|
||||
|
||||
const traceId = `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const url = priceListExportFormat.value === 'excel'
|
||||
? '/pricing/products/price-list/export-excel'
|
||||
: '/pricing/products/price-list/export-pdf'
|
||||
|
||||
const res = await api.request({
|
||||
method: 'POST',
|
||||
url,
|
||||
data: payload,
|
||||
responseType: 'blob',
|
||||
timeout: 0,
|
||||
headers: { 'X-Trace-ID': traceId }
|
||||
})
|
||||
const blob = res?.data instanceof Blob ? res.data : new Blob([res?.data || ''])
|
||||
const objUrl = URL.createObjectURL(blob)
|
||||
|
||||
if (priceListExportFormat.value === 'pdf') {
|
||||
window.open(objUrl, '_blank')
|
||||
setTimeout(() => URL.revokeObjectURL(objUrl), 120000)
|
||||
} else {
|
||||
const a = document.createElement('a')
|
||||
a.href = objUrl
|
||||
a.download = `baggi_guncel_fiyat_listesi_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(objUrl)
|
||||
}
|
||||
|
||||
priceListExportDialogOpen.value = false
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.parsedMessage || err?.message || 'Fiyat listesi olusturulamadi' })
|
||||
} finally {
|
||||
priceListExportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetGroupSelections () {
|
||||
topUrunIlkGrubu.value = null
|
||||
topUrunAnaGrubu.value = []
|
||||
applyTopGroupFiltersToColumnFilters()
|
||||
// Keep other local filters cleared too, so page is "clean render".
|
||||
store.rows = []
|
||||
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
|
||||
store.error = GUIDANCE_MSG
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
@@ -749,6 +1192,8 @@ const headerFilterFieldSet = new Set([
|
||||
])
|
||||
|
||||
const mainTableRef = ref(null)
|
||||
const topScrollRef = ref(null)
|
||||
const topScrollInnerRef = ref(null)
|
||||
const tablePagination = ref({
|
||||
page: 1, // server-side paging var; q-table local paging kapali
|
||||
rowsPerPage: 0,
|
||||
@@ -756,16 +1201,37 @@ const tablePagination = ref({
|
||||
descending: true
|
||||
})
|
||||
const selectedMap = ref({})
|
||||
const bulkDialogOpen = ref(false)
|
||||
const bulkField = ref('expenseForBasePrice')
|
||||
const bulkValue = ref('')
|
||||
const selectedCurrencies = ref(['USD', 'EUR', 'TRY'])
|
||||
const exportAllLoading = ref(false)
|
||||
const showSelectedOnly = ref(false)
|
||||
const leftDetailsExpanded = ref(true)
|
||||
const calcLoadingMap = ref({})
|
||||
const bulkCalcLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const priceHistoryDialogOpen = ref(false)
|
||||
const priceHistoryRow = ref(null)
|
||||
const priceHistoryLoading = ref(false)
|
||||
const priceHistoryTab = ref('pg')
|
||||
const priceHistory = ref({ postgres: [], mssql: [] })
|
||||
const selectedPgIds = ref([])
|
||||
const selectedMssqlIds = ref([])
|
||||
|
||||
const priceListExportDialogOpen = ref(false)
|
||||
const priceListExportFormat = ref('pdf') // 'pdf' | 'excel'
|
||||
const priceListExportLoading = ref(false)
|
||||
const priceListInStockOnly = ref(true)
|
||||
const priceListUrunIlkGrubu = ref(null)
|
||||
const priceListUrunAnaGrubu = ref([])
|
||||
const priceListUrunAltGrubu = ref([])
|
||||
const priceListIncludeCost = ref(true)
|
||||
const priceListIncludeBase = ref(true)
|
||||
const priceListUSDLevels = ref([1, 2, 3, 4, 5, 6])
|
||||
const priceListEURLevels = ref([1, 2, 3, 4, 5, 6])
|
||||
const priceListTRYLevels = ref([1, 2, 3, 4, 5, 6])
|
||||
|
||||
const editableColumns = [
|
||||
'costPrice',
|
||||
'expenseForBasePrice',
|
||||
'basePriceUsd',
|
||||
'basePriceTry',
|
||||
'usd1',
|
||||
@@ -809,6 +1275,7 @@ const allColumns = [
|
||||
col('marka', 'MARKA', 'marka', 54, { sortable: true, classes: 'ps-col' }),
|
||||
col('productCode', 'URUN KODU', 'productCode', 108, { sortable: true, classes: 'ps-col product-code-col' }),
|
||||
col('calcAction', 'HESAPLA', 'calcAction', 72, { align: 'center', classes: 'ps-col' }),
|
||||
col('historyAction', '', 'historyAction', 40, { align: 'center', classes: 'ps-col text-center' }),
|
||||
col('stockQty', 'STOK ADET', 'stockQty', 72, { align: 'right', sortable: true, classes: 'ps-col stock-col' }),
|
||||
col('stockEntryDate', 'STOK GIRIS TARIHI', 'stockEntryDate', 92, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
|
||||
col('lastCostingDate', 'SON MALIYETLENDIRME', 'lastCostingDate', 110, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
|
||||
@@ -820,36 +1287,48 @@ const allColumns = [
|
||||
col('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 66, { sortable: true, classes: 'ps-col' }),
|
||||
col('icerik', 'ICERIK', 'icerik', 62, { sortable: true, classes: 'ps-col' }),
|
||||
col('karisim', 'KARISIM', 'karisim', 62, { sortable: true, classes: 'ps-col' }),
|
||||
col('costPrice', 'MALIYET FIYATI', 'costPrice', 74, { align: 'right', sortable: true, classes: 'usd-col' }),
|
||||
col('expenseForBasePrice', 'TABAN FIYAT MASRAF', 'expenseForBasePrice', 86, { align: 'right', classes: 'usd-col' }),
|
||||
col('basePriceUsd', 'TABAN USD', 'basePriceUsd', 74, { align: 'right', classes: 'usd-col' }),
|
||||
col('basePriceTry', 'TABAN TRY', 'basePriceTry', 74, { align: 'right', classes: 'try-col' }),
|
||||
col('usd1', 'USD 1', 'usd1', 62, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd2', 'USD 2', 'usd2', 62, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd3', 'USD 3', 'usd3', 62, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd4', 'USD 4', 'usd4', 62, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd5', 'USD 5', 'usd5', 62, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd6', 'USD 6', 'usd6', 62, { align: 'right', classes: 'usd-col' }),
|
||||
col('eur1', 'EUR 1', 'eur1', 62, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur2', 'EUR 2', 'eur2', 62, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur3', 'EUR 3', 'eur3', 62, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur4', 'EUR 4', 'eur4', 62, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur5', 'EUR 5', 'eur5', 62, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur6', 'EUR 6', 'eur6', 62, { align: 'right', classes: 'eur-col' }),
|
||||
col('try1', 'TRY 1', 'try1', 62, { align: 'right', classes: 'try-col' }),
|
||||
col('try2', 'TRY 2', 'try2', 62, { align: 'right', classes: 'try-col' }),
|
||||
col('try3', 'TRY 3', 'try3', 62, { align: 'right', classes: 'try-col' }),
|
||||
col('try4', 'TRY 4', 'try4', 62, { align: 'right', classes: 'try-col' }),
|
||||
col('try5', 'TRY 5', 'try5', 62, { align: 'right', classes: 'try-col' }),
|
||||
col('try6', 'TRY 6', 'try6', 62, { align: 'right', classes: 'try-col' })
|
||||
col('costPrice', 'MALIYET FIYATI', 'costPrice', 88, { align: 'right', sortable: true, classes: 'usd-col' }),
|
||||
col('basePriceUsd', 'TABAN USD', 'basePriceUsd', 88, { align: 'right', classes: 'usd-col' }),
|
||||
col('basePriceTry', 'TABAN TRY', 'basePriceTry', 96, { align: 'right', classes: 'try-col' }),
|
||||
col('usd1', 'USD 1', 'usd1', 84, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd2', 'USD 2', 'usd2', 84, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd3', 'USD 3', 'usd3', 84, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd4', 'USD 4', 'usd4', 84, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd5', 'USD 5', 'usd5', 84, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd6', 'USD 6', 'usd6', 84, { align: 'right', classes: 'usd-col' }),
|
||||
col('eur1', 'EUR 1', 'eur1', 84, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur2', 'EUR 2', 'eur2', 84, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur3', 'EUR 3', 'eur3', 84, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur4', 'EUR 4', 'eur4', 84, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur5', 'EUR 5', 'eur5', 84, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur6', 'EUR 6', 'eur6', 84, { align: 'right', classes: 'eur-col' }),
|
||||
col('try1', 'TRY 1', 'try1', 96, { align: 'right', classes: 'try-col' }),
|
||||
col('try2', 'TRY 2', 'try2', 96, { align: 'right', classes: 'try-col' }),
|
||||
col('try3', 'TRY 3', 'try3', 96, { align: 'right', classes: 'try-col' }),
|
||||
col('try4', 'TRY 4', 'try4', 96, { align: 'right', classes: 'try-col' }),
|
||||
col('try5', 'TRY 5', 'try5', 96, { align: 'right', classes: 'try-col' }),
|
||||
col('try6', 'TRY 6', 'try6', 96, { align: 'right', classes: 'try-col' })
|
||||
]
|
||||
|
||||
const stickyColumnNames = [
|
||||
const hideableLeftDetailColumnNames = new Set([
|
||||
'stockEntryDate',
|
||||
'lastCostingDate',
|
||||
'lastPricingDate',
|
||||
'askiliYan',
|
||||
'kategori',
|
||||
'urunIlkGrubu',
|
||||
'urunAnaGrubu',
|
||||
'urunAltGrubu',
|
||||
'icerik',
|
||||
'karisim'
|
||||
])
|
||||
const stickyColumnNamesBase = [
|
||||
'select',
|
||||
'brandGroupSelection',
|
||||
'marka',
|
||||
'productCode',
|
||||
'calcAction',
|
||||
'historyAction',
|
||||
'stockQty',
|
||||
'stockEntryDate',
|
||||
'lastPricingDate',
|
||||
@@ -861,12 +1340,10 @@ const stickyColumnNames = [
|
||||
'icerik',
|
||||
'karisim',
|
||||
'costPrice',
|
||||
'expenseForBasePrice',
|
||||
'basePriceUsd',
|
||||
'basePriceTry'
|
||||
]
|
||||
const stickyBoundaryColumnName = 'basePriceTry'
|
||||
const stickyColumnNameSet = new Set(stickyColumnNames)
|
||||
|
||||
const visibleColumns = computed(() => {
|
||||
const selected = new Set(selectedCurrencies.value)
|
||||
@@ -874,16 +1351,61 @@ const visibleColumns = computed(() => {
|
||||
if (c.name.startsWith('usd')) return selected.has('USD')
|
||||
if (c.name.startsWith('eur')) return selected.has('EUR')
|
||||
if (c.name.startsWith('try')) return selected.has('TRY')
|
||||
if (!leftDetailsExpanded.value && hideableLeftDetailColumnNames.has(c.name)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const exportableColumns = computed(() => visibleColumns.value.filter((col) => col.name !== 'select' && col.name !== 'calcAction'))
|
||||
const stickyColumnNames = computed(() => {
|
||||
const visibleNameSet = new Set(visibleColumns.value.map((col) => col.name))
|
||||
return stickyColumnNamesBase.filter((name) => visibleNameSet.has(name))
|
||||
})
|
||||
const stickyColumnNameSet = computed(() => new Set(stickyColumnNames.value))
|
||||
|
||||
const exportableColumns = computed(() => visibleColumns.value.filter((col) => col.name !== 'select' && col.name !== 'calcAction' && col.name !== 'historyAction'))
|
||||
|
||||
const pgHistoryGroups = computed(() => {
|
||||
const list = Array.isArray(priceHistory.value?.postgres) ? priceHistory.value.postgres : []
|
||||
const map = new Map()
|
||||
for (const r of list) {
|
||||
const currency = String(r?.currency || '').toUpperCase().trim()
|
||||
const levelNo = Number(r?.level_no || 0)
|
||||
if (!currency || !(levelNo >= 1 && levelNo <= 6)) continue
|
||||
const key = `${currency}|${levelNo}`
|
||||
if (!map.has(key)) map.set(key, { key, currency, levelNo, rows: [] })
|
||||
map.get(key).rows.push(r)
|
||||
}
|
||||
const out = Array.from(map.values())
|
||||
for (const g of out) g.latest = g.rows?.[0] || null
|
||||
out.sort((a, b) => (a.currency + a.levelNo).localeCompare(b.currency + b.levelNo))
|
||||
return out
|
||||
})
|
||||
|
||||
const mssqlHistoryGroups = computed(() => {
|
||||
const list = Array.isArray(priceHistory.value?.mssql) ? priceHistory.value.mssql : []
|
||||
const map = new Map()
|
||||
for (const r of list) {
|
||||
const currency = String(r?.currency || '').toUpperCase().trim()
|
||||
const pgc = String(r?.price_group_code || '').trim()
|
||||
if (!currency || !pgc) continue
|
||||
const key = `${currency}|${pgc}`
|
||||
if (!map.has(key)) map.set(key, { key, currency, price_group_code: pgc, rows: [] })
|
||||
map.get(key).rows.push(r)
|
||||
}
|
||||
const out = Array.from(map.values())
|
||||
for (const g of out) g.latest = g.rows?.[0] || null
|
||||
out.sort((a, b) => (a.currency + a.price_group_code).localeCompare(b.currency + b.price_group_code))
|
||||
return out
|
||||
})
|
||||
|
||||
const selectedPgIdSet = computed(() => new Set(selectedPgIds.value || []))
|
||||
const selectedMssqlIdSet = computed(() => new Set(selectedMssqlIds.value || []))
|
||||
const selectedHistoryCount = computed(() => (selectedPgIds.value?.length || 0) + (selectedMssqlIds.value?.length || 0))
|
||||
|
||||
const stickyLeftMap = computed(() => {
|
||||
const map = {}
|
||||
let left = 0
|
||||
for (const colName of stickyColumnNames) {
|
||||
for (const colName of stickyColumnNames.value) {
|
||||
const c = allColumns.find((x) => x.name === colName)
|
||||
if (!c) continue
|
||||
map[colName] = left
|
||||
@@ -893,9 +1415,8 @@ const stickyLeftMap = computed(() => {
|
||||
})
|
||||
const stickyScrollComp = computed(() => {
|
||||
const boundaryCol = allColumns.find((x) => x.name === stickyBoundaryColumnName)
|
||||
return (stickyLeftMap.value[stickyBoundaryColumnName] || 0) + extractWidth(boundaryCol?.style)
|
||||
return ((stickyLeftMap.value[stickyBoundaryColumnName] || 0) + extractWidth(boundaryCol?.style)) * 1.2
|
||||
})
|
||||
|
||||
const tableMinWidth = computed(() => visibleColumns.value.reduce((sum, c) => sum + extractWidth(c.style), 0))
|
||||
const tableStyle = computed(() => ({
|
||||
width: `${tableMinWidth.value}px`,
|
||||
@@ -905,16 +1426,9 @@ const tableStyle = computed(() => ({
|
||||
|
||||
const rows = computed(() => store.rows || [])
|
||||
const tableLoading = computed(() => Boolean(store.loading) && rows.value.length === 0)
|
||||
const bulkFieldOptions = computed(() => {
|
||||
return editableColumns
|
||||
.map((name) => {
|
||||
const colDef = allColumns.find((c) => c.field === name)
|
||||
return {
|
||||
value: name,
|
||||
label: colDef?.label || name
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const isGuidanceState = computed(() => String(store.error || '').trim() === GUIDANCE_MSG)
|
||||
const showGuidanceOverlay = computed(() => isGuidanceState.value && !store.loading && rows.value.length === 0)
|
||||
const multiFilterOptionMap = computed(() => {
|
||||
const map = {}
|
||||
multiFilterColumns.forEach(({ field }) => {
|
||||
@@ -1136,7 +1650,7 @@ function extractWidth (style) {
|
||||
}
|
||||
|
||||
function isStickyCol (colName) {
|
||||
return stickyColumnNameSet.has(colName)
|
||||
return stickyColumnNameSet.value.has(colName)
|
||||
}
|
||||
|
||||
function isStickyBoundary (colName) {
|
||||
@@ -1226,6 +1740,28 @@ function formatDateDisplay (val) {
|
||||
return `${day}.${month}.${year}`
|
||||
}
|
||||
|
||||
function formatMoney (v) {
|
||||
const n = Number(v ?? 0)
|
||||
if (!Number.isFinite(n)) return '-'
|
||||
return n.toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 6 })
|
||||
}
|
||||
|
||||
function formatMssqlStamp (row) {
|
||||
if (!row) return '-'
|
||||
const vd = String(row?.valid_date || '').trim()
|
||||
const vt = String(row?.valid_time || '').trim()
|
||||
const lud = String(row?.last_updated_date || '').trim()
|
||||
const parts = []
|
||||
if (vd) parts.push(vd)
|
||||
if (vt) parts.push(vt)
|
||||
if (lud) parts.push(`upd:${lud}`)
|
||||
return parts.length ? parts.join(' ') : '-'
|
||||
}
|
||||
|
||||
function getOriginalCellValue (row, field) {
|
||||
return row?.[`__orig_${field}`] ?? row?.[field] ?? 0
|
||||
}
|
||||
|
||||
function exportCellValue (row, field) {
|
||||
if (field === 'stockQty') return formatStock(row?.[field])
|
||||
if (field === 'stockEntryDate' || field === 'lastCostingDate' || field === 'lastPricingDate') return formatDateDisplay(row?.[field])
|
||||
@@ -1337,24 +1873,252 @@ function needsCosting (row) {
|
||||
}
|
||||
|
||||
function recalcByBasePrice (row) {
|
||||
row.basePriceTry = round2((row.basePriceUsd * usdToTry) + row.expenseForBasePrice)
|
||||
row.basePriceTry = round2(row.basePriceUsd * usdToTry)
|
||||
let prevUsd = row.basePriceUsd
|
||||
let prevTry = row.basePriceTry
|
||||
let prevEur = round2(row.basePriceUsd * usdToTry / eurToTry)
|
||||
multipliers.forEach((multiplier, index) => {
|
||||
row[`usd${index + 1}`] = round2(row.basePriceUsd * multiplier)
|
||||
row[`eur${index + 1}`] = round2((row.basePriceUsd * usdToTry * multiplier) / eurToTry)
|
||||
row[`try${index + 1}`] = round2(row.basePriceTry * multiplier)
|
||||
const nextUsd = round2(prevUsd * multiplier)
|
||||
const nextTry = round2(prevTry * multiplier)
|
||||
const nextEur = round2(prevEur * multiplier)
|
||||
row[`usd${index + 1}`] = nextUsd
|
||||
row[`eur${index + 1}`] = nextEur
|
||||
row[`try${index + 1}`] = nextTry
|
||||
prevUsd = nextUsd
|
||||
prevTry = nextTry
|
||||
prevEur = nextEur
|
||||
})
|
||||
}
|
||||
|
||||
function onEditableCellChange (row, field, val) {
|
||||
const parsed = parseNumber(val)
|
||||
store.updateCell(row, field, parsed)
|
||||
if (field === 'expenseForBasePrice' || field === 'basePriceUsd') recalcByBasePrice(row)
|
||||
if (field === 'basePriceUsd') recalcByBasePrice(row)
|
||||
}
|
||||
|
||||
function calculateRow (row) {
|
||||
if (!row) return
|
||||
recalcByBasePrice(row)
|
||||
function setCalcLoading (productCode, value) {
|
||||
calcLoadingMap.value = {
|
||||
...calcLoadingMap.value,
|
||||
[productCode]: !!value
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreviewRowToUiRow (row, preview) {
|
||||
row.basePriceUsd = round2(preview?.base_price_usd)
|
||||
row.basePriceTry = round2(preview?.base_price_try)
|
||||
row.usd1 = round2(preview?.usd1)
|
||||
row.usd2 = round2(preview?.usd2)
|
||||
row.usd3 = round2(preview?.usd3)
|
||||
row.usd4 = round2(preview?.usd4)
|
||||
row.usd5 = round2(preview?.usd5)
|
||||
row.usd6 = round2(preview?.usd6)
|
||||
row.eur1 = round2(preview?.eur1)
|
||||
row.eur2 = round2(preview?.eur2)
|
||||
row.eur3 = round2(preview?.eur3)
|
||||
row.eur4 = round2(preview?.eur4)
|
||||
row.eur5 = round2(preview?.eur5)
|
||||
row.eur6 = round2(preview?.eur6)
|
||||
row.try1 = round2(preview?.try1)
|
||||
row.try2 = round2(preview?.try2)
|
||||
row.try3 = round2(preview?.try3)
|
||||
row.try4 = round2(preview?.try4)
|
||||
row.try5 = round2(preview?.try5)
|
||||
row.try6 = round2(preview?.try6)
|
||||
}
|
||||
|
||||
async function calculateRow (row) {
|
||||
if (!row?.productCode) return
|
||||
const productCode = String(row.productCode).trim()
|
||||
if (!productCode) return
|
||||
|
||||
setCalcLoading(productCode, true)
|
||||
console.info('[product-pricing][ui] calc-row:start', { product_code: productCode })
|
||||
try {
|
||||
const res = await api.post('/pricing/products/calculate-snapshots', {
|
||||
preview_only: true,
|
||||
product_codes: [productCode]
|
||||
}, {
|
||||
timeout: 180000
|
||||
})
|
||||
const list = Array.isArray(res?.data?.rows) ? res.data.rows : []
|
||||
const preview = list.find((item) => String(item?.product_code || '').trim() === productCode)
|
||||
if (!preview) {
|
||||
Notify.create({ type: 'warning', message: 'Bu urun icin hesap sonucu donmedi.' })
|
||||
return
|
||||
}
|
||||
applyPreviewRowToUiRow(row, preview)
|
||||
toggleRowSelection(rowSelectionKey(row), true)
|
||||
console.info('[product-pricing][ui] calc-row:done', { product_code: productCode })
|
||||
} catch (err) {
|
||||
console.error('[product-pricing][ui] calc-row:error', {
|
||||
product_code: productCode,
|
||||
status: err?.response?.status ?? null,
|
||||
message: err?.response?.data || err?.message || 'calc-row failed'
|
||||
})
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Hesaplama onizlemesi alinamadi' })
|
||||
} finally {
|
||||
setCalcLoading(productCode, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openPriceHistoryDialog (row) {
|
||||
if (!row?.productCode) return
|
||||
priceHistoryRow.value = row
|
||||
priceHistoryTab.value = 'pg'
|
||||
priceHistory.value = { postgres: [], mssql: [] }
|
||||
selectedPgIds.value = []
|
||||
selectedMssqlIds.value = []
|
||||
priceHistoryDialogOpen.value = true
|
||||
await reloadPriceHistory()
|
||||
}
|
||||
|
||||
async function reloadPriceHistory () {
|
||||
const code = String(priceHistoryRow.value?.productCode || '').trim()
|
||||
if (!code) return
|
||||
priceHistoryLoading.value = true
|
||||
try {
|
||||
const traceId = `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const res = await api.request({
|
||||
method: 'GET',
|
||||
url: `/pricing/products/${encodeURIComponent(code)}/price-history`,
|
||||
timeout: 180000,
|
||||
headers: { 'X-Trace-ID': traceId }
|
||||
})
|
||||
priceHistory.value = {
|
||||
postgres: Array.isArray(res?.data?.postgres) ? res.data.postgres : [],
|
||||
mssql: Array.isArray(res?.data?.mssql) ? res.data.mssql : []
|
||||
}
|
||||
// keep selection but drop ids that no longer exist
|
||||
const pgSet = new Set(priceHistory.value.postgres.map((r) => String(r?.id || '').trim()).filter(Boolean))
|
||||
const msSet = new Set(priceHistory.value.mssql.map((r) => String(r?.price_list_line_id || '').trim()).filter(Boolean))
|
||||
selectedPgIds.value = (selectedPgIds.value || []).filter((id) => pgSet.has(id))
|
||||
selectedMssqlIds.value = (selectedMssqlIds.value || []).filter((id) => msSet.has(id))
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Fiyat gecmisi yuklenemedi' })
|
||||
} finally {
|
||||
priceHistoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectedPgId (id, val) {
|
||||
const sid = String(id || '').trim()
|
||||
if (!sid) return
|
||||
const set = new Set(selectedPgIds.value || [])
|
||||
if (val) set.add(sid)
|
||||
else set.delete(sid)
|
||||
selectedPgIds.value = Array.from(set)
|
||||
}
|
||||
|
||||
function toggleSelectedMssqlId (id, val) {
|
||||
const sid = String(id || '').trim()
|
||||
if (!sid) return
|
||||
const set = new Set(selectedMssqlIds.value || [])
|
||||
if (val) set.add(sid)
|
||||
else set.delete(sid)
|
||||
selectedMssqlIds.value = Array.from(set)
|
||||
}
|
||||
|
||||
async function confirmDeleteSelectedHistory () {
|
||||
const code = String(priceHistoryRow.value?.productCode || '').trim()
|
||||
if (!code) return
|
||||
|
||||
const pgCount = selectedPgIds.value?.length || 0
|
||||
const msCount = selectedMssqlIds.value?.length || 0
|
||||
if (pgCount + msCount === 0) return
|
||||
|
||||
await $q.dialog({
|
||||
title: 'Secilenleri Sil',
|
||||
message: `Secili kayitlari silmek istiyor musunuz? (B2B/B2C: ${pgCount}, NEBIM_V3: ${msCount})`,
|
||||
cancel: true,
|
||||
persistent: true,
|
||||
ok: { label: 'Sil', color: 'negative' },
|
||||
cancel: { label: 'Vazgec', color: 'grey-7', flat: true }
|
||||
}).onOk(async () => {
|
||||
const traceId = `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const payload = {
|
||||
pg_ids: (selectedPgIds.value || []).map((x) => String(x || '').trim()).filter(Boolean),
|
||||
mssql_ids: (selectedMssqlIds.value || []).map((x) => String(x || '').trim()).filter(Boolean)
|
||||
}
|
||||
await api.request({
|
||||
method: 'POST',
|
||||
url: `/pricing/products/${encodeURIComponent(code)}/price-history/delete-selected`,
|
||||
data: payload,
|
||||
timeout: 180000,
|
||||
headers: { 'X-Trace-ID': traceId }
|
||||
})
|
||||
Notify.create({ type: 'positive', message: 'Secilen kayitlar silindi.' })
|
||||
selectedPgIds.value = []
|
||||
selectedMssqlIds.value = []
|
||||
await reloadPriceHistory()
|
||||
await reloadData({ page: currentPage.value })
|
||||
})
|
||||
}
|
||||
|
||||
let tableMiddleScrollEl = null
|
||||
let horizontalResizeObserver = null
|
||||
let syncingTopScroll = false
|
||||
|
||||
function getTableMiddleScrollEl () {
|
||||
return mainTableRef.value?.$el?.querySelector('.q-table__middle') || null
|
||||
}
|
||||
|
||||
function syncTopScrollWidth () {
|
||||
const top = topScrollRef.value
|
||||
const inner = topScrollInnerRef.value
|
||||
const middle = getTableMiddleScrollEl()
|
||||
if (!top || !inner || !middle) return
|
||||
const scrollWidth = Math.max(middle.scrollWidth, tableMinWidth.value, top.clientWidth)
|
||||
inner.style.width = `${scrollWidth}px`
|
||||
if (top.scrollLeft !== middle.scrollLeft) {
|
||||
top.scrollLeft = middle.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
function onTopScroll () {
|
||||
const top = topScrollRef.value
|
||||
const middle = getTableMiddleScrollEl()
|
||||
if (!top || !middle || syncingTopScroll) return
|
||||
syncingTopScroll = true
|
||||
middle.scrollLeft = top.scrollLeft
|
||||
requestAnimationFrame(() => {
|
||||
syncingTopScroll = false
|
||||
})
|
||||
}
|
||||
|
||||
function onTableMiddleScroll () {
|
||||
const top = topScrollRef.value
|
||||
const middle = getTableMiddleScrollEl()
|
||||
if (!top || !middle || syncingTopScroll) return
|
||||
syncingTopScroll = true
|
||||
top.scrollLeft = middle.scrollLeft
|
||||
requestAnimationFrame(() => {
|
||||
syncingTopScroll = false
|
||||
})
|
||||
}
|
||||
|
||||
async function bindHorizontalScrollSync () {
|
||||
await nextTick()
|
||||
const middle = getTableMiddleScrollEl()
|
||||
if (tableMiddleScrollEl && tableMiddleScrollEl !== middle) {
|
||||
tableMiddleScrollEl.removeEventListener('scroll', onTableMiddleScroll)
|
||||
}
|
||||
tableMiddleScrollEl = middle
|
||||
if (tableMiddleScrollEl) {
|
||||
tableMiddleScrollEl.removeEventListener('scroll', onTableMiddleScroll)
|
||||
tableMiddleScrollEl.addEventListener('scroll', onTableMiddleScroll, { passive: true })
|
||||
}
|
||||
if (horizontalResizeObserver) {
|
||||
horizontalResizeObserver.disconnect()
|
||||
horizontalResizeObserver = null
|
||||
}
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
horizontalResizeObserver = new ResizeObserver(() => {
|
||||
syncTopScrollWidth()
|
||||
})
|
||||
if (topScrollRef.value) horizontalResizeObserver.observe(topScrollRef.value)
|
||||
if (tableMiddleScrollEl) horizontalResizeObserver.observe(tableMiddleScrollEl)
|
||||
}
|
||||
syncTopScrollWidth()
|
||||
}
|
||||
|
||||
function onBrandGroupSelectionChange (row, val) {
|
||||
@@ -1362,7 +2126,35 @@ function onBrandGroupSelectionChange (row, val) {
|
||||
}
|
||||
|
||||
function isRowSelected (rowKey) {
|
||||
return !!selectedMap.value[rowKey]
|
||||
const k = String(rowKey ?? '').trim()
|
||||
if (!k) return false
|
||||
return !!selectedMap.value[k]
|
||||
}
|
||||
|
||||
const selectedToneColumnNameSet = new Set([
|
||||
// "Karisim"e kadar olan sol kolonlar (fiyat kolonlarini boyamayalim)
|
||||
'brandGroupSelection',
|
||||
'marka',
|
||||
'productCode',
|
||||
'stockQty',
|
||||
'stockEntryDate',
|
||||
'lastCostingDate',
|
||||
'lastPricingDate',
|
||||
'askiliYan',
|
||||
'kategori',
|
||||
'urunIlkGrubu',
|
||||
'urunAnaGrubu',
|
||||
'urunAltGrubu',
|
||||
'icerik',
|
||||
'karisim'
|
||||
])
|
||||
|
||||
function shouldToneSelectedCell (row, colName) {
|
||||
if (!selectedToneColumnNameSet.has(String(colName || '').trim())) return false
|
||||
if (!isRowSelected(rowSelectionKey(row))) return false
|
||||
// don't override critical warning coloring
|
||||
if (String(colName || '').trim() === 'lastCostingDate' && needsCosting(row)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function onRowCheckboxChange (row, val) {
|
||||
@@ -1371,7 +2163,142 @@ function onRowCheckboxChange (row, val) {
|
||||
}
|
||||
|
||||
function toggleRowSelection (rowKey, val) {
|
||||
selectedMap.value = { ...selectedMap.value, [rowKey]: !!val }
|
||||
const k = String(rowKey ?? '').trim()
|
||||
if (!k) return
|
||||
selectedMap.value = { ...selectedMap.value, [k]: !!val }
|
||||
}
|
||||
|
||||
function isRowDirty (row) {
|
||||
if (!row) return false
|
||||
const fields = [
|
||||
'basePriceUsd',
|
||||
'basePriceTry',
|
||||
'usd1', 'usd2', 'usd3', 'usd4', 'usd5', 'usd6',
|
||||
'eur1', 'eur2', 'eur3', 'eur4', 'eur5', 'eur6',
|
||||
'try1', 'try2', 'try3', 'try4', 'try5', 'try6'
|
||||
]
|
||||
for (const f of fields) {
|
||||
const cur = Number(row?.[f] ?? 0)
|
||||
const orig = Number(row?.[`__orig_${f}`] ?? 0)
|
||||
if (Math.abs(cur - orig) > 1e-9) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedRows = computed(() => {
|
||||
const map = selectedMap.value || {}
|
||||
return rows.value.filter((r) => !!map[rowSelectionKey(r)])
|
||||
})
|
||||
|
||||
const selectedDirtyRows = computed(() => selectedRows.value.filter(isRowDirty))
|
||||
const selectedDirtyCount = computed(() => selectedDirtyRows.value.length)
|
||||
const saveButtonLabel = computed(() => {
|
||||
if (selectedDirtyCount.value > 0) return `Kaydet (${selectedDirtyCount.value})`
|
||||
return 'Kaydet'
|
||||
})
|
||||
|
||||
async function calculateSelectedRows () {
|
||||
const list = selectedRows.value
|
||||
if (!Array.isArray(list) || list.length === 0) return
|
||||
const productCodes = list
|
||||
.map((r) => String(r?.productCode || '').trim())
|
||||
.filter(Boolean)
|
||||
if (productCodes.length === 0) return
|
||||
|
||||
bulkCalcLoading.value = true
|
||||
console.info('[product-pricing][ui] bulk-calc:start', { selected: productCodes.length })
|
||||
try {
|
||||
const res = await api.post('/pricing/products/calculate-snapshots', {
|
||||
preview_only: true,
|
||||
product_codes: productCodes
|
||||
}, {
|
||||
timeout: 180000
|
||||
})
|
||||
const previewRows = Array.isArray(res?.data?.rows) ? res.data.rows : []
|
||||
const byCode = new Map(previewRows.map((p) => [String(p?.product_code || '').trim(), p]))
|
||||
let applied = 0
|
||||
for (const row of rows.value) {
|
||||
const code = String(row?.productCode || '').trim()
|
||||
if (!code) continue
|
||||
if (!selectedMap.value?.[rowSelectionKey(row)]) continue
|
||||
const p = byCode.get(code)
|
||||
if (!p) continue
|
||||
applyPreviewRowToUiRow(row, p)
|
||||
applied++
|
||||
}
|
||||
Notify.create({ type: 'positive', message: `Hesaplandi: ${applied} / ${productCodes.length}` })
|
||||
console.info('[product-pricing][ui] bulk-calc:done', { applied, selected: productCodes.length })
|
||||
} catch (err) {
|
||||
console.error('[product-pricing][ui] bulk-calc:error', {
|
||||
status: err?.response?.status ?? null,
|
||||
message: err?.response?.data || err?.message || 'bulk-calc failed'
|
||||
})
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Toplu hesaplama basarisiz' })
|
||||
} finally {
|
||||
bulkCalcLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelectedRows () {
|
||||
const list = selectedDirtyRows.value
|
||||
if (!Array.isArray(list) || list.length === 0) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const traceId = `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
console.info('[product-pricing][ui] save:start', { trace_id: traceId, dirty_count: list.length })
|
||||
const payload = {
|
||||
items: list.map((r) => ({
|
||||
product_code: String(r?.productCode || '').trim(),
|
||||
base_price_usd: Number(r?.basePriceUsd ?? 0),
|
||||
base_price_try: Number(r?.basePriceTry ?? 0),
|
||||
usd1: Number(r?.usd1 ?? 0),
|
||||
usd2: Number(r?.usd2 ?? 0),
|
||||
usd3: Number(r?.usd3 ?? 0),
|
||||
usd4: Number(r?.usd4 ?? 0),
|
||||
usd5: Number(r?.usd5 ?? 0),
|
||||
usd6: Number(r?.usd6 ?? 0),
|
||||
eur1: Number(r?.eur1 ?? 0),
|
||||
eur2: Number(r?.eur2 ?? 0),
|
||||
eur3: Number(r?.eur3 ?? 0),
|
||||
eur4: Number(r?.eur4 ?? 0),
|
||||
eur5: Number(r?.eur5 ?? 0),
|
||||
eur6: Number(r?.eur6 ?? 0),
|
||||
try1: Number(r?.try1 ?? 0),
|
||||
try2: Number(r?.try2 ?? 0),
|
||||
try3: Number(r?.try3 ?? 0),
|
||||
try4: Number(r?.try4 ?? 0),
|
||||
try5: Number(r?.try5 ?? 0),
|
||||
try6: Number(r?.try6 ?? 0)
|
||||
}))
|
||||
}
|
||||
|
||||
await api.request({
|
||||
method: 'POST',
|
||||
url: '/pricing/products/save',
|
||||
data: payload,
|
||||
timeout: 0,
|
||||
headers: { 'X-Trace-ID': traceId }
|
||||
})
|
||||
|
||||
Notify.create({ type: 'positive', message: `Kaydedildi: ${list.length}` })
|
||||
console.info('[product-pricing][ui] save:done', { trace_id: traceId, dirty_count: list.length })
|
||||
|
||||
// After persisting, clear selection state and reload from backend.
|
||||
// This avoids "Kaydet(1) but checkbox not ticked" confusion and ensures UI reflects DB.
|
||||
selectedMap.value = {}
|
||||
showSelectedOnly.value = false
|
||||
await reloadData({ page: currentPage.value, useCache: false })
|
||||
} catch (err) {
|
||||
console.error('[product-pricing][ui] save:error', {
|
||||
status: err?.response?.status ?? null,
|
||||
trace_id: err?.response?.headers?.['x-trace-id'] || null,
|
||||
message: err?.response?.data || err?.message || 'save failed'
|
||||
})
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kaydedilemedi' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAllVisible (val) {
|
||||
@@ -1380,20 +2307,6 @@ function toggleSelectAllVisible (val) {
|
||||
selectedMap.value = next
|
||||
}
|
||||
|
||||
function applyBulkUpdate () {
|
||||
const field = String(bulkField.value || '').trim()
|
||||
if (!field || !editableColumnSet.has(field)) return
|
||||
const parsed = parseNumber(bulkValue.value)
|
||||
rows.value.forEach((row) => {
|
||||
if (!isRowSelected(rowSelectionKey(row))) return
|
||||
store.updateCell(row, field, parsed)
|
||||
if (field === 'expenseForBasePrice' || field === 'basePriceUsd') {
|
||||
recalcByBasePrice(row)
|
||||
}
|
||||
})
|
||||
bulkDialogOpen.value = false
|
||||
}
|
||||
|
||||
function resetAll () {
|
||||
columnFilters.value = {
|
||||
productCode: [],
|
||||
@@ -1500,7 +2413,7 @@ function scheduleReload () {
|
||||
}, 180)
|
||||
}
|
||||
|
||||
async function fetchChunk ({ page = 1 } = {}) {
|
||||
async function fetchChunk ({ page = 1, useCache = true } = {}) {
|
||||
const filters = buildServerFilters()
|
||||
const hasAnyFilter = Object.values(filters).some((v) => Array.isArray(v) && v.length > 0)
|
||||
const hasPrimaryFilter = (filters.urun_ilk_grubu?.length || 0) > 0 || (filters.urun_ana_grubu?.length || 0) > 0
|
||||
@@ -1516,7 +2429,7 @@ async function fetchChunk ({ page = 1 } = {}) {
|
||||
}
|
||||
if (!hasPrimaryFilter) {
|
||||
store.rows = []
|
||||
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
|
||||
store.error = GUIDANCE_MSG
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
@@ -1528,6 +2441,7 @@ async function fetchChunk ({ page = 1 } = {}) {
|
||||
page,
|
||||
append: false,
|
||||
silent: false,
|
||||
useCache,
|
||||
filters,
|
||||
sortBy: tablePagination.value.sortBy,
|
||||
descending: tablePagination.value.descending
|
||||
@@ -1536,13 +2450,13 @@ async function fetchChunk ({ page = 1 } = {}) {
|
||||
return Number(result?.fetched) || 0
|
||||
}
|
||||
|
||||
async function reloadData ({ page = 1 } = {}) {
|
||||
async function reloadData ({ page = 1, useCache = true } = {}) {
|
||||
const startedAt = Date.now()
|
||||
console.info('[product-pricing][ui] reload:start', {
|
||||
at: new Date(startedAt).toISOString()
|
||||
})
|
||||
try {
|
||||
await fetchChunk({ page })
|
||||
await fetchChunk({ page, useCache })
|
||||
} catch (err) {
|
||||
console.error('[product-pricing][ui] reload:error', {
|
||||
duration_ms: Date.now() - startedAt,
|
||||
@@ -1554,6 +2468,7 @@ async function reloadData ({ page = 1 } = {}) {
|
||||
row_count: Array.isArray(store.rows) ? store.rows.length : 0,
|
||||
has_error: Boolean(store.error)
|
||||
})
|
||||
await bindHorizontalScrollSync()
|
||||
}
|
||||
|
||||
// Full "fetch all pages" is intentionally avoided; keep server-side paging for performance.
|
||||
@@ -1572,18 +2487,34 @@ onMounted(async () => {
|
||||
void fetchServerFilterOptions('urunAnaGrubu')
|
||||
// Do not auto-fetch listing on mount; user must scope by group first.
|
||||
store.rows = []
|
||||
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
|
||||
store.error = GUIDANCE_MSG
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
store.hasMore = false
|
||||
await bindHorizontalScrollSync()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [tableMinWidth.value, rows.value.length, selectedCurrencies.value.join(',')],
|
||||
() => {
|
||||
void bindHorizontalScrollSync()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (reloadTimer) {
|
||||
clearTimeout(reloadTimer)
|
||||
reloadTimer = null
|
||||
}
|
||||
if (tableMiddleScrollEl) {
|
||||
tableMiddleScrollEl.removeEventListener('scroll', onTableMiddleScroll)
|
||||
tableMiddleScrollEl = null
|
||||
}
|
||||
if (horizontalResizeObserver) {
|
||||
horizontalResizeObserver.disconnect()
|
||||
horizontalResizeObserver = null
|
||||
}
|
||||
})
|
||||
|
||||
// NOTE: Listing fetch is intentionally manual via "Gruplari Getir" for performance.
|
||||
@@ -1595,6 +2526,7 @@ onBeforeUnmount(() => {
|
||||
--pricing-header-height: 72px;
|
||||
--pricing-table-height: calc(100vh - 210px);
|
||||
|
||||
position: relative;
|
||||
height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1608,16 +2540,96 @@ onBeforeUnmount(() => {
|
||||
.top-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.top-actions-row {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.top-actions-row--filters {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.top-actions-row--actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* paging group is inside actions row now */
|
||||
|
||||
.toolbar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.toolbar-group--paging {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-group--paging :deep(.q-pagination) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn__content) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn--dense .q-btn__content) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn__content .q-icon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn .q-icon) {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn__content span) {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn) {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn__content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn__wrapper) {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.top-actions-row--filters,
|
||||
.top-actions-row--actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -1627,15 +2639,65 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-overlay-inner {
|
||||
width: min(720px, 100%);
|
||||
border: 1px dashed rgba(0, 0, 0, 0.18);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 16px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.price-history-card {
|
||||
width: 980px;
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.price-history-card :deep(.q-card__section) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.price-history-card :deep(.q-tab-panels) {
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.top-x-scroll {
|
||||
flex: 0 0 14px;
|
||||
height: 14px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.top-x-scroll-inner {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.pane-table {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pricing-table :deep(.q-table__middle) {
|
||||
height: var(--pricing-table-height);
|
||||
min-height: var(--pricing-table-height);
|
||||
max-height: var(--pricing-table-height);
|
||||
height: calc(var(--pricing-table-height) - 14px);
|
||||
min-height: calc(var(--pricing-table-height) - 14px);
|
||||
max-height: calc(var(--pricing-table-height) - 14px);
|
||||
overflow: auto !important;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
overscroll-behavior: contain;
|
||||
@@ -1661,7 +2723,7 @@ onBeforeUnmount(() => {
|
||||
.pricing-table :deep(th),
|
||||
.pricing-table :deep(td) {
|
||||
box-sizing: border-box;
|
||||
padding: 0 4px;
|
||||
padding: 0 1px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -1681,7 +2743,7 @@ onBeforeUnmount(() => {
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding: 0 4px !important;
|
||||
padding: 0 1px !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th),
|
||||
@@ -1725,6 +2787,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.pricing-table :deep(tbody .sticky-col) {
|
||||
z-index: 12 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(.sticky-boundary) {
|
||||
@@ -1732,6 +2795,20 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 8px 0 12px -10px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.pricing-table :deep(tbody td:not(.sticky-col)) {
|
||||
position: relative;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(tbody td.sticky-col)::after,
|
||||
.pricing-table :deep(thead th.sticky-col)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: inherit;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.header-with-filter {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 20px;
|
||||
@@ -1830,6 +2907,11 @@ onBeforeUnmount(() => {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.pricing-table :deep(td.selected-tone-cell) {
|
||||
/* "Secondary" tonlu secim vurgusu (yalnizca karisima kadar olan sol kolonlar) */
|
||||
background: color-mix(in srgb, var(--q-secondary) 12%, #ffffff);
|
||||
}
|
||||
|
||||
.stock-qty-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -1879,7 +2961,6 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.pricing-table :deep(.selection-col .q-checkbox__bg) {
|
||||
background: #fff;
|
||||
border-color: var(--q-primary);
|
||||
}
|
||||
|
||||
@@ -1922,17 +3003,49 @@ onBeforeUnmount(() => {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.editable-price-cell {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.old-price-label {
|
||||
display: block;
|
||||
width: 90%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #7c3aed;
|
||||
text-align: right;
|
||||
margin: 0 auto;
|
||||
padding-right: 1px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.native-cell-input,
|
||||
.native-cell-select {
|
||||
width: 100%;
|
||||
width: 90%;
|
||||
height: 22px;
|
||||
box-sizing: border-box;
|
||||
padding: 1px 3px;
|
||||
padding: 1px 1px;
|
||||
border: 1px solid #cfd8dc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
margin: 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.price-edit-input {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.native-cell-input:focus,
|
||||
|
||||
@@ -382,6 +382,13 @@ const routes = [
|
||||
component: () => import('pages/BrandClassification.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/brandgroupcurrency',
|
||||
alias: ['pricing/brandproupcurrency', 'pricing/brand-group-currency'],
|
||||
name: 'brandgroupcurrency',
|
||||
component: () => import('pages/BrandGroupCurrency.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/pricing-rules',
|
||||
name: 'pricing-rules',
|
||||
|
||||
@@ -119,7 +119,8 @@ api.interceptors.request.use((config) => {
|
||||
trace_id: traceId,
|
||||
method: String(config.method || 'GET').toUpperCase(),
|
||||
url,
|
||||
params: config.params || {}
|
||||
params: config.params || {},
|
||||
timeout_ms: typeof config.timeout === 'number' ? config.timeout : null
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function parseFlexibleNumber (value) {
|
||||
}
|
||||
|
||||
function mapRow (raw, index, baseIndex = 0) {
|
||||
return {
|
||||
const row = {
|
||||
id: baseIndex + index + 1,
|
||||
productCode: toText(raw?.ProductCode),
|
||||
stockQty: toNumber(raw?.StockQty),
|
||||
@@ -76,6 +76,18 @@ function mapRow (raw, index, baseIndex = 0) {
|
||||
try5: toNumber(raw?.TRY5),
|
||||
try6: toNumber(raw?.TRY6)
|
||||
}
|
||||
const originalFields = [
|
||||
'costPrice',
|
||||
'basePriceUsd',
|
||||
'basePriceTry',
|
||||
'usd1', 'usd2', 'usd3', 'usd4', 'usd5', 'usd6',
|
||||
'eur1', 'eur2', 'eur3', 'eur4', 'eur5', 'eur6',
|
||||
'try1', 'try2', 'try3', 'try4', 'try5', 'try6'
|
||||
]
|
||||
originalFields.forEach((field) => {
|
||||
row[`__orig_${field}`] = row[field]
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
function cloneRows (rows = []) {
|
||||
|
||||
Reference in New Issue
Block a user