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",
|
"pricing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.ExportAllProductPricingHandler)),
|
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,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/brand-classification/lookups", "GET",
|
"/api/pricing/brand-classification/lookups", "GET",
|
||||||
"pricing", "view",
|
"pricing", "view",
|
||||||
@@ -830,6 +865,16 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
|||||||
"pricing", "update",
|
"pricing", "update",
|
||||||
wrapV3(routes.SetBrandGroupsBulkHandler(pgDB)),
|
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,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/pricing-rules", "GET",
|
"/api/pricing/pricing-rules", "GET",
|
||||||
"pricing", "view",
|
"pricing", "view",
|
||||||
@@ -1162,6 +1207,9 @@ func main() {
|
|||||||
if err := queries.EnsurePricingParameterTables(pgDB); err != nil {
|
if err := queries.EnsurePricingParameterTables(pgDB); err != nil {
|
||||||
log.Println("mk_urunpricingprmtr bootstrap failed:", err)
|
log.Println("mk_urunpricingprmtr bootstrap failed:", err)
|
||||||
}
|
}
|
||||||
|
if err := queries.EnsurePricingCalcInfraTables(pgDB); err != nil {
|
||||||
|
log.Println("pricing calc infra bootstrap failed:", err)
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------
|
// -------------------------------------------------------
|
||||||
// ✉️ MAILER INIT
|
// ✉️ MAILER INIT
|
||||||
@@ -1184,6 +1232,8 @@ func main() {
|
|||||||
startTranslationSyncScheduler(pgDB, db.MssqlDB)
|
startTranslationSyncScheduler(pgDB, db.MssqlDB)
|
||||||
startBrandSyncScheduler(pgDB, db.MssqlDB)
|
startBrandSyncScheduler(pgDB, db.MssqlDB)
|
||||||
startPricingParameterSyncScheduler(pgDB, db.MssqlDB)
|
startPricingParameterSyncScheduler(pgDB, db.MssqlDB)
|
||||||
|
startProductPricingFxDeltaScheduler(pgDB)
|
||||||
|
startProductPricingFxFullScheduler(pgDB)
|
||||||
|
|
||||||
handler := enableCORS(
|
handler := enableCORS(
|
||||||
middlewares.GlobalAuthMiddleware(
|
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"`
|
Code string `json:"code"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
AnchorMode string `json:"anchor_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func EnsureBrandClassificationTables(pg *sql.DB) error {
|
func EnsureBrandClassificationTables(pg *sql.DB) error {
|
||||||
@@ -41,10 +42,15 @@ CREATE TABLE IF NOT EXISTS mk_brandgrp (
|
|||||||
code TEXT NOT NULL UNIQUE,
|
code TEXT NOT NULL UNIQUE,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
anchor_mode TEXT NOT NULL DEFAULT 'USD',
|
||||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
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 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)
|
INSERT INTO mk_brandgrp (id, code, title, description, sort_order)
|
||||||
VALUES
|
VALUES
|
||||||
@@ -74,7 +80,7 @@ CREATE TABLE IF NOT EXISTS mk_brandgrpmatch (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -82,17 +88,57 @@ func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error
|
|||||||
out := make([]BrandGroupOption, 0, 8)
|
out := make([]BrandGroupOption, 0, 8)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var o BrandGroupOption
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
o.Code = strings.TrimSpace(o.Code)
|
o.Code = strings.TrimSpace(o.Code)
|
||||||
o.Title = strings.TrimSpace(o.Title)
|
o.Title = strings.TrimSpace(o.Title)
|
||||||
o.Description = strings.TrimSpace(o.Description)
|
o.Description = strings.TrimSpace(o.Description)
|
||||||
|
o.AnchorMode = strings.ToUpper(strings.TrimSpace(o.AnchorMode))
|
||||||
|
if o.AnchorMode == "" {
|
||||||
|
o.AnchorMode = "USD"
|
||||||
|
}
|
||||||
out = append(out, o)
|
out = append(out, o)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
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) {
|
func ListBrandsWithGroups(ctx context.Context, pg *sql.DB, q string, limit int) ([]BrandRow, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 5000
|
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
|
icerik, marka, brand_code, brand_group_sec, scope_key
|
||||||
FROM mk_urunpricingprmtr
|
FROM mk_urunpricingprmtr
|
||||||
WHERE id=$1
|
WHERE id=$1
|
||||||
AND is_active=TRUE
|
|
||||||
`, pricingParameterID).Scan(
|
`, pricingParameterID).Scan(
|
||||||
&p.AskiliYan,
|
&p.AskiliYan,
|
||||||
&p.Kategori,
|
&p.Kategori,
|
||||||
@@ -441,6 +440,12 @@ WHERE ProductAtt42 IN ('SERI', 'AKSESUAR')
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
CREATE TEMP TABLE tmp_urunpricingprmtr_sync (
|
CREATE TEMP TABLE tmp_urunpricingprmtr_sync (
|
||||||
askili_yan TEXT NOT NULL,
|
askili_yan TEXT NOT NULL,
|
||||||
@@ -714,6 +719,17 @@ SELECT
|
|||||||
p.brand_code,
|
p.brand_code,
|
||||||
p.brand_group_sec,
|
p.brand_group_sec,
|
||||||
COALESCE(r.id::text, ''),
|
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(r.is_active, TRUE),
|
||||||
|
|
||||||
COALESCE(tx.base_mult, 0)::float8,
|
COALESCE(tx.base_mult, 0)::float8,
|
||||||
@@ -725,6 +741,7 @@ SELECT
|
|||||||
COALESCE(tx.m6, 0)::float8,
|
COALESCE(tx.m6, 0)::float8,
|
||||||
COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8,
|
COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8,
|
||||||
COALESCE(NULLIF(tr.retail_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.base_mult, 0)::float8,
|
||||||
COALESCE(ux.m1, 0)::float8,
|
COALESCE(ux.m1, 0)::float8,
|
||||||
@@ -735,6 +752,7 @@ SELECT
|
|||||||
COALESCE(ux.m6, 0)::float8,
|
COALESCE(ux.m6, 0)::float8,
|
||||||
COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8,
|
COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8,
|
||||||
COALESCE(NULLIF(ur.retail_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.base_mult, 0)::float8,
|
||||||
COALESCE(ex.m1, 0)::float8,
|
COALESCE(ex.m1, 0)::float8,
|
||||||
@@ -744,7 +762,8 @@ SELECT
|
|||||||
COALESCE(ex.m5, 0)::float8,
|
COALESCE(ex.m5, 0)::float8,
|
||||||
COALESCE(ex.m6, 0)::float8,
|
COALESCE(ex.m6, 0)::float8,
|
||||||
COALESCE(NULLIF(er.wholesale_step, 0), er.step, 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
|
FROM mk_urunpricingprmtr p
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT latest_rule.*
|
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
|
ORDER BY latest_rule.created_at DESC, latest_rule.updated_at DESC, latest_rule.id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) r ON TRUE
|
) 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 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 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'
|
LEFT JOIN mk_pricex ex ON ex.rule_id = r.id AND ex.currency='EUR'
|
||||||
@@ -790,13 +817,21 @@ ORDER BY
|
|||||||
&item.BrandCode,
|
&item.BrandCode,
|
||||||
&item.BrandGroupSec,
|
&item.BrandGroupSec,
|
||||||
&rule.ID,
|
&rule.ID,
|
||||||
|
&rule.StrategyCode,
|
||||||
|
&rule.AnchorMode,
|
||||||
|
&rule.CalcEnabled,
|
||||||
|
&rule.PublishPostgres,
|
||||||
|
&rule.PublishNebim,
|
||||||
&rule.IsActive,
|
&rule.IsActive,
|
||||||
&rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryWholesaleStep, &rule.TryRetailStep,
|
&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.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.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurWholesaleStep, &rule.EurRetailStep, &rule.EurRetailMode,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
rule.TryRetailMode = normalizeRetailMode(rule.TryRetailMode)
|
||||||
|
rule.UsdRetailMode = normalizeRetailMode(rule.UsdRetailMode)
|
||||||
|
rule.EurRetailMode = normalizeRetailMode(rule.EurRetailMode)
|
||||||
rule.PricingParameterID = item.PricingParameterID
|
rule.PricingParameterID = item.PricingParameterID
|
||||||
rule.AskiliYan = pricingParameterScopeValue(item.AskiliYan)
|
rule.AskiliYan = pricingParameterScopeValue(item.AskiliYan)
|
||||||
rule.Kategori = pricingParameterScopeValue(item.Kategori)
|
rule.Kategori = pricingParameterScopeValue(item.Kategori)
|
||||||
@@ -809,9 +844,7 @@ ORDER BY
|
|||||||
rule.BrandCode = pricingParameterScopeValue(item.BrandCode)
|
rule.BrandCode = pricingParameterScopeValue(item.BrandCode)
|
||||||
rule.BrandGroupSec = pricingParameterScopeValue(item.BrandGroupSec)
|
rule.BrandGroupSec = pricingParameterScopeValue(item.BrandGroupSec)
|
||||||
item.HasRule = strings.TrimSpace(rule.ID) != ""
|
item.HasRule = strings.TrimSpace(rule.ID) != ""
|
||||||
if item.HasRule {
|
|
||||||
item.Rule = &rule
|
item.Rule = &rule
|
||||||
}
|
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package queries
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,6 +16,22 @@ import (
|
|||||||
// - mk_pricex: per-currency multipliers (base + 1..6).
|
// - mk_pricex: per-currency multipliers (base + 1..6).
|
||||||
// - mk_priceroll: per-currency rounding steps for wholesale (1-5) and retail (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 {
|
func EnsurePricingRuleTables(pg *sql.DB) error {
|
||||||
stmts := []string{
|
stmts := []string{
|
||||||
`
|
`
|
||||||
@@ -32,10 +49,26 @@ CREATE TABLE IF NOT EXISTS mk_pricing_rule (
|
|||||||
brand_code TEXT[] NOT NULL DEFAULT '{}'::text[],
|
brand_code TEXT[] NOT NULL DEFAULT '{}'::text[],
|
||||||
brand_group 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,
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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 INDEX IF NOT EXISTS ix_mk_pricing_rule_active ON mk_pricing_rule (is_active)`,
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS mk_pricex (
|
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,
|
step NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||||
wholesale_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_step NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||||
|
retail_mode TEXT NOT NULL DEFAULT 'STEP',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
PRIMARY KEY (rule_id, currency)
|
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 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_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 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)`,
|
`CREATE INDEX IF NOT EXISTS ix_mk_priceroll_currency ON mk_priceroll (currency)`,
|
||||||
}
|
}
|
||||||
for _, s := range stmts {
|
for _, s := range stmts {
|
||||||
@@ -92,6 +128,11 @@ type PricingRuleRow struct {
|
|||||||
BrandCode []string `json:"brand_code"`
|
BrandCode []string `json:"brand_code"`
|
||||||
BrandGroupSec []string `json:"brand_group"`
|
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"`
|
IsActive bool `json:"is_active"`
|
||||||
|
|
||||||
// multipliers/rolls are per currency
|
// multipliers/rolls are per currency
|
||||||
@@ -104,6 +145,7 @@ type PricingRuleRow struct {
|
|||||||
Try6 float64 `json:"try6"`
|
Try6 float64 `json:"try6"`
|
||||||
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
||||||
TryRetailStep float64 `json:"try_retail_step"`
|
TryRetailStep float64 `json:"try_retail_step"`
|
||||||
|
TryRetailMode string `json:"try_retail_mode"`
|
||||||
|
|
||||||
UsdBase float64 `json:"usd_base"`
|
UsdBase float64 `json:"usd_base"`
|
||||||
Usd1 float64 `json:"usd1"`
|
Usd1 float64 `json:"usd1"`
|
||||||
@@ -114,6 +156,7 @@ type PricingRuleRow struct {
|
|||||||
Usd6 float64 `json:"usd6"`
|
Usd6 float64 `json:"usd6"`
|
||||||
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
||||||
UsdRetailStep float64 `json:"usd_retail_step"`
|
UsdRetailStep float64 `json:"usd_retail_step"`
|
||||||
|
UsdRetailMode string `json:"usd_retail_mode"`
|
||||||
|
|
||||||
EurBase float64 `json:"eur_base"`
|
EurBase float64 `json:"eur_base"`
|
||||||
Eur1 float64 `json:"eur1"`
|
Eur1 float64 `json:"eur1"`
|
||||||
@@ -124,6 +167,7 @@ type PricingRuleRow struct {
|
|||||||
Eur6 float64 `json:"eur6"`
|
Eur6 float64 `json:"eur6"`
|
||||||
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
||||||
EurRetailStep float64 `json:"eur_retail_step"`
|
EurRetailStep float64 `json:"eur_retail_step"`
|
||||||
|
EurRetailMode string `json:"eur_retail_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PricingRuleSaveItem struct {
|
type PricingRuleSaveItem struct {
|
||||||
@@ -141,6 +185,11 @@ type PricingRuleSaveItem struct {
|
|||||||
BrandCode []string `json:"brand_code"`
|
BrandCode []string `json:"brand_code"`
|
||||||
BrandGroupSec []string `json:"brand_group"`
|
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"`
|
IsActive bool `json:"is_active"`
|
||||||
|
|
||||||
TryBase float64 `json:"try_base"`
|
TryBase float64 `json:"try_base"`
|
||||||
@@ -152,6 +201,7 @@ type PricingRuleSaveItem struct {
|
|||||||
Try6 float64 `json:"try6"`
|
Try6 float64 `json:"try6"`
|
||||||
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
||||||
TryRetailStep float64 `json:"try_retail_step"`
|
TryRetailStep float64 `json:"try_retail_step"`
|
||||||
|
TryRetailMode string `json:"try_retail_mode"`
|
||||||
|
|
||||||
UsdBase float64 `json:"usd_base"`
|
UsdBase float64 `json:"usd_base"`
|
||||||
Usd1 float64 `json:"usd1"`
|
Usd1 float64 `json:"usd1"`
|
||||||
@@ -162,6 +212,7 @@ type PricingRuleSaveItem struct {
|
|||||||
Usd6 float64 `json:"usd6"`
|
Usd6 float64 `json:"usd6"`
|
||||||
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
||||||
UsdRetailStep float64 `json:"usd_retail_step"`
|
UsdRetailStep float64 `json:"usd_retail_step"`
|
||||||
|
UsdRetailMode string `json:"usd_retail_mode"`
|
||||||
|
|
||||||
EurBase float64 `json:"eur_base"`
|
EurBase float64 `json:"eur_base"`
|
||||||
Eur1 float64 `json:"eur1"`
|
Eur1 float64 `json:"eur1"`
|
||||||
@@ -172,6 +223,174 @@ type PricingRuleSaveItem struct {
|
|||||||
Eur6 float64 `json:"eur6"`
|
Eur6 float64 `json:"eur6"`
|
||||||
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
||||||
EurRetailStep float64 `json:"eur_retail_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) {
|
func ListPricingRules(ctx context.Context, pg *sql.DB) ([]PricingRuleRow, error) {
|
||||||
@@ -190,6 +409,11 @@ SELECT
|
|||||||
r.marka,
|
r.marka,
|
||||||
r.brand_code,
|
r.brand_code,
|
||||||
r.brand_group,
|
r.brand_group,
|
||||||
|
r.strategy_code,
|
||||||
|
r.anchor_mode,
|
||||||
|
r.calc_enabled,
|
||||||
|
r.publish_postgres,
|
||||||
|
r.publish_nebim,
|
||||||
r.is_active,
|
r.is_active,
|
||||||
|
|
||||||
COALESCE(tx.base_mult, 0)::float8 AS try_base,
|
COALESCE(tx.base_mult, 0)::float8 AS try_base,
|
||||||
@@ -201,6 +425,7 @@ SELECT
|
|||||||
COALESCE(tx.m6, 0)::float8 AS try6,
|
COALESCE(tx.m6, 0)::float8 AS try6,
|
||||||
COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8 AS try_wholesale_step,
|
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(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.base_mult, 0)::float8 AS usd_base,
|
||||||
COALESCE(ux.m1, 0)::float8 AS usd1,
|
COALESCE(ux.m1, 0)::float8 AS usd1,
|
||||||
@@ -211,6 +436,7 @@ SELECT
|
|||||||
COALESCE(ux.m6, 0)::float8 AS usd6,
|
COALESCE(ux.m6, 0)::float8 AS usd6,
|
||||||
COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8 AS usd_wholesale_step,
|
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(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.base_mult, 0)::float8 AS eur_base,
|
||||||
COALESCE(ex.m1, 0)::float8 AS eur1,
|
COALESCE(ex.m1, 0)::float8 AS eur1,
|
||||||
@@ -220,7 +446,8 @@ SELECT
|
|||||||
COALESCE(ex.m5, 0)::float8 AS eur5,
|
COALESCE(ex.m5, 0)::float8 AS eur5,
|
||||||
COALESCE(ex.m6, 0)::float8 AS eur6,
|
COALESCE(ex.m6, 0)::float8 AS eur6,
|
||||||
COALESCE(NULLIF(er.wholesale_step, 0), er.step, 0)::float8 AS eur_wholesale_step,
|
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
|
FROM mk_pricing_rule r
|
||||||
LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY'
|
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 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.Marka),
|
||||||
pq.Array(&r.BrandCode),
|
pq.Array(&r.BrandCode),
|
||||||
pq.Array(&r.BrandGroupSec),
|
pq.Array(&r.BrandGroupSec),
|
||||||
|
&r.StrategyCode,
|
||||||
|
&r.AnchorMode,
|
||||||
|
&r.CalcEnabled,
|
||||||
|
&r.PublishPostgres,
|
||||||
|
&r.PublishNebim,
|
||||||
&r.IsActive,
|
&r.IsActive,
|
||||||
|
|
||||||
&r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryWholesaleStep, &r.TryRetailStep,
|
&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.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.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurWholesaleStep, &r.EurRetailStep, &r.EurRetailMode,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
r.TryRetailMode = normalizeRetailMode(r.TryRetailMode)
|
||||||
|
r.UsdRetailMode = normalizeRetailMode(r.UsdRetailMode)
|
||||||
|
r.EurRetailMode = normalizeRetailMode(r.EurRetailMode)
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
@@ -282,6 +517,42 @@ func normalizeTextList(in []string) []string {
|
|||||||
return out
|
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.
|
// UpsertPricingRule persists rule scope + per-currency multipliers/roundings.
|
||||||
// Parameter-backed worksheet saves append a new rule version so older prices
|
// Parameter-backed worksheet saves append a new rule version so older prices
|
||||||
// remain queryable. Legacy rules without a parameter id keep update behavior.
|
// 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.Marka = normalizeTextList(item.Marka)
|
||||||
item.BrandCode = normalizeTextList(item.BrandCode)
|
item.BrandCode = normalizeTextList(item.BrandCode)
|
||||||
item.BrandGroupSec = normalizeTextList(item.BrandGroupSec)
|
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)
|
id := strings.TrimSpace(item.ID)
|
||||||
if item.PricingParameterID > 0 {
|
if item.PricingParameterID > 0 {
|
||||||
@@ -317,12 +593,15 @@ func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem
|
|||||||
INSERT INTO mk_pricing_rule (
|
INSERT INTO mk_pricing_rule (
|
||||||
pricing_parameter_id,
|
pricing_parameter_id,
|
||||||
askili_yan,kategori,urun_ilk_grubu,urun_ana_grubu,urun_alt_grubu,
|
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
|
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),
|
`, 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),
|
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,
|
item.IsActive,
|
||||||
).Scan(&id); err != nil {
|
).Scan(&id); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -341,13 +620,19 @@ UPDATE mk_pricing_rule SET
|
|||||||
marka=$10,
|
marka=$10,
|
||||||
brand_code=$11,
|
brand_code=$11,
|
||||||
brand_group=$12,
|
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()
|
updated_at=now()
|
||||||
WHERE id=$1
|
WHERE id=$1
|
||||||
`, id,
|
`, id,
|
||||||
nullablePricingParameterID(item.PricingParameterID),
|
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.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),
|
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,
|
item.IsActive,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -371,41 +656,176 @@ ON CONFLICT (rule_id, currency) DO UPDATE SET
|
|||||||
`, id, cur, base, m1, m2, m3, m4, m5, m6)
|
`, id, cur, base, m1, m2, m3, m4, m5, m6)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
upsertRoll := func(cur string, wholesaleStep, retailStep float64) error {
|
upsertRoll := func(cur string, wholesaleStep, retailStep float64, retailMode string) error {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, created_at, updated_at)
|
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, retail_mode, created_at, updated_at)
|
||||||
VALUES ($1,$2,$3,$4,$5,now(),now())
|
VALUES ($1,$2,$3,$4,$5,$6,now(),now())
|
||||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||||
step=EXCLUDED.step,
|
step=EXCLUDED.step,
|
||||||
wholesale_step=EXCLUDED.wholesale_step,
|
wholesale_step=EXCLUDED.wholesale_step,
|
||||||
retail_step=EXCLUDED.retail_step,
|
retail_step=EXCLUDED.retail_step,
|
||||||
|
retail_mode=EXCLUDED.retail_mode,
|
||||||
updated_at=now()
|
updated_at=now()
|
||||||
`, id, cur, wholesaleStep, wholesaleStep, retailStep)
|
`, id, cur, wholesaleStep, wholesaleStep, retailStep, retailMode)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil {
|
if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil {
|
||||||
return "", err
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil {
|
if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil {
|
||||||
return "", err
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil {
|
if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil {
|
||||||
return "", err
|
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 "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return id, nil
|
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 {
|
func nullablePricingParameterID(id int64) any {
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ func GetAllProductPricingRows(ctx context.Context, chunkSize int, filters Produc
|
|||||||
orderExpr = "rc.ProductCode"
|
orderExpr = "rc.ProductCode"
|
||||||
orderDir = "ASC"
|
orderDir = "ASC"
|
||||||
}
|
}
|
||||||
|
orderBySQL := orderExpr + ` ` + orderDir
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(orderExpr), "rc.ProductCode") {
|
||||||
|
orderBySQL += `,
|
||||||
|
rc.ProductCode ASC`
|
||||||
|
}
|
||||||
|
|
||||||
baseQuery := `
|
baseQuery := `
|
||||||
IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes;
|
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
|
LEFT JOIN #disp_base db
|
||||||
ON db.ItemCode = rc.ProductCode
|
ON db.ItemCode = rc.ProductCode
|
||||||
ORDER BY
|
ORDER BY
|
||||||
` + orderExpr + ` ` + orderDir + `,
|
` + orderBySQL + `;
|
||||||
rc.ProductCode ASC;
|
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := db.MssqlDB.QueryContext(ctx, baseQuery, args...)
|
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"
|
orderExpr = "rc.ProductCode"
|
||||||
orderDir = "ASC"
|
orderDir = "ASC"
|
||||||
}
|
}
|
||||||
|
orderBySQL := orderExpr + ` ` + orderDir
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(orderExpr), "rc.ProductCode") {
|
||||||
|
orderBySQL += `,
|
||||||
|
rc.ProductCode ASC`
|
||||||
|
}
|
||||||
productQuery := `
|
productQuery := `
|
||||||
IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes;
|
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;
|
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
|
LEFT JOIN #stock_base sb
|
||||||
ON sb.ItemCode = rc.ProductCode
|
ON sb.ItemCode = rc.ProductCode
|
||||||
ORDER BY
|
ORDER BY
|
||||||
` + orderExpr + ` ` + orderDir + `,
|
` + orderBySQL + `
|
||||||
rc.ProductCode ASC
|
|
||||||
OFFSET ` + strconv.Itoa(offset) + ` ROWS
|
OFFSET ` + strconv.Itoa(offset) + ` ROWS
|
||||||
FETCH NEXT ` + strconv.Itoa(limit) + ` ROWS ONLY;
|
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
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bssapp-backend/auth"
|
||||||
"bssapp-backend/queries"
|
"bssapp-backend/queries"
|
||||||
"bssapp-backend/utils"
|
"bssapp-backend/utils"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/lib/pq"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -33,6 +35,11 @@ type PricingRuleImportItem struct {
|
|||||||
Marka string `json:"marka"`
|
Marka string `json:"marka"`
|
||||||
BrandCode string `json:"brand_code"`
|
BrandCode string `json:"brand_code"`
|
||||||
BrandGroupSec string `json:"brand_group"`
|
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"`
|
IsActive bool `json:"is_active"`
|
||||||
TryBase float64 `json:"try_base"`
|
TryBase float64 `json:"try_base"`
|
||||||
Try1 float64 `json:"try1"`
|
Try1 float64 `json:"try1"`
|
||||||
@@ -43,6 +50,7 @@ type PricingRuleImportItem struct {
|
|||||||
Try6 float64 `json:"try6"`
|
Try6 float64 `json:"try6"`
|
||||||
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
||||||
TryRetailStep float64 `json:"try_retail_step"`
|
TryRetailStep float64 `json:"try_retail_step"`
|
||||||
|
TryRetailMode string `json:"try_retail_mode"`
|
||||||
UsdBase float64 `json:"usd_base"`
|
UsdBase float64 `json:"usd_base"`
|
||||||
Usd1 float64 `json:"usd1"`
|
Usd1 float64 `json:"usd1"`
|
||||||
Usd2 float64 `json:"usd2"`
|
Usd2 float64 `json:"usd2"`
|
||||||
@@ -52,6 +60,7 @@ type PricingRuleImportItem struct {
|
|||||||
Usd6 float64 `json:"usd6"`
|
Usd6 float64 `json:"usd6"`
|
||||||
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
||||||
UsdRetailStep float64 `json:"usd_retail_step"`
|
UsdRetailStep float64 `json:"usd_retail_step"`
|
||||||
|
UsdRetailMode string `json:"usd_retail_mode"`
|
||||||
EurBase float64 `json:"eur_base"`
|
EurBase float64 `json:"eur_base"`
|
||||||
Eur1 float64 `json:"eur1"`
|
Eur1 float64 `json:"eur1"`
|
||||||
Eur2 float64 `json:"eur2"`
|
Eur2 float64 `json:"eur2"`
|
||||||
@@ -61,6 +70,7 @@ type PricingRuleImportItem struct {
|
|||||||
Eur6 float64 `json:"eur6"`
|
Eur6 float64 `json:"eur6"`
|
||||||
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
||||||
EurRetailStep float64 `json:"eur_retail_step"`
|
EurRetailStep float64 `json:"eur_retail_step"`
|
||||||
|
EurRetailMode string `json:"eur_retail_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PricingRuleImportPayload struct {
|
type PricingRuleImportPayload struct {
|
||||||
@@ -77,6 +87,52 @@ type PricingRuleImportResult struct {
|
|||||||
ErrorCount int `json:"error_count"`
|
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 {
|
func GetPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
@@ -104,37 +160,130 @@ func SavePricingRulesBulkHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
traceID := utils.TraceIDFromRequest(r)
|
traceID := utils.TraceIDFromRequest(r)
|
||||||
|
w.Header().Set("X-Trace-ID", traceID)
|
||||||
ctx := utils.ContextWithTraceID(r.Context(), 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)
|
tx, err := pg.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Error("bulk-save:tx-begin:error", "err", err)
|
||||||
http.Error(w, "pg transaction start error", http.StatusInternalServerError)
|
http.Error(w, "pg transaction start error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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
|
updated := 0
|
||||||
for _, it := range payload.Items {
|
for _, it := range payload.Items {
|
||||||
// Zero means that no rounding rule has been configured yet.
|
// 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 {
|
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)
|
http.Error(w, "invalid rounding step", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, err := queries.UpsertPricingRule(ctx, tx, it)
|
if !isValidPricingStrategyCode(it.StrategyCode) {
|
||||||
if err != nil {
|
logger.Warn("bulk-save:invalid-strategy-code",
|
||||||
http.Error(w, "pricing rule save error", http.StatusInternalServerError)
|
"pricing_parameter_id", it.PricingParameterID,
|
||||||
|
"id", strings.TrimSpace(it.ID),
|
||||||
|
"strategy_code", it.StrategyCode,
|
||||||
|
)
|
||||||
|
http.Error(w, "invalid strategy_code", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if id != "" {
|
if !isValidPricingAnchorMode(it.AnchorMode) {
|
||||||
updated++
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
|
logger.Error("bulk-save:commit:error", "err", err)
|
||||||
http.Error(w, "pg transaction commit error", http.StatusInternalServerError)
|
http.Error(w, "pg transaction commit error", http.StatusInternalServerError)
|
||||||
return
|
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})
|
_ = 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()
|
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
|
updated := 0
|
||||||
matched := 0
|
matched := 0
|
||||||
skipped := 0
|
skipped := 0
|
||||||
@@ -171,6 +326,18 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
http.Error(w, "invalid rounding step", http.StatusBadRequest)
|
http.Error(w, "invalid rounding step", http.StatusBadRequest)
|
||||||
return
|
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(
|
pricingParameterID, err := queries.FindActivePricingParameterByScope(ctx, tx, queries.PricingParameterRowForImport(
|
||||||
raw.AskiliYan,
|
raw.AskiliYan,
|
||||||
@@ -195,6 +362,11 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
_, err = queries.UpsertPricingRule(ctx, tx, queries.PricingRuleSaveItem{
|
_, err = queries.UpsertPricingRule(ctx, tx, queries.PricingRuleSaveItem{
|
||||||
PricingParameterID: pricingParameterID,
|
PricingParameterID: pricingParameterID,
|
||||||
|
StrategyCode: normalizePricingStrategyCode(raw.StrategyCode),
|
||||||
|
AnchorMode: normalizePricingAnchorMode(raw.AnchorMode),
|
||||||
|
CalcEnabled: raw.CalcEnabled,
|
||||||
|
PublishPostgres: raw.PublishPostgres,
|
||||||
|
PublishNebim: raw.PublishNebim,
|
||||||
IsActive: raw.IsActive,
|
IsActive: raw.IsActive,
|
||||||
TryBase: raw.TryBase,
|
TryBase: raw.TryBase,
|
||||||
Try1: raw.Try1,
|
Try1: raw.Try1,
|
||||||
@@ -205,6 +377,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
Try6: raw.Try6,
|
Try6: raw.Try6,
|
||||||
TryWholesaleStep: raw.TryWholesaleStep,
|
TryWholesaleStep: raw.TryWholesaleStep,
|
||||||
TryRetailStep: raw.TryRetailStep,
|
TryRetailStep: raw.TryRetailStep,
|
||||||
|
TryRetailMode: queries.NormalizeRetailModeForRoute(raw.TryRetailMode),
|
||||||
UsdBase: raw.UsdBase,
|
UsdBase: raw.UsdBase,
|
||||||
Usd1: raw.Usd1,
|
Usd1: raw.Usd1,
|
||||||
Usd2: raw.Usd2,
|
Usd2: raw.Usd2,
|
||||||
@@ -214,6 +387,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
Usd6: raw.Usd6,
|
Usd6: raw.Usd6,
|
||||||
UsdWholesaleStep: raw.UsdWholesaleStep,
|
UsdWholesaleStep: raw.UsdWholesaleStep,
|
||||||
UsdRetailStep: raw.UsdRetailStep,
|
UsdRetailStep: raw.UsdRetailStep,
|
||||||
|
UsdRetailMode: queries.NormalizeRetailModeForRoute(raw.UsdRetailMode),
|
||||||
EurBase: raw.EurBase,
|
EurBase: raw.EurBase,
|
||||||
Eur1: raw.Eur1,
|
Eur1: raw.Eur1,
|
||||||
Eur2: raw.Eur2,
|
Eur2: raw.Eur2,
|
||||||
@@ -223,6 +397,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
Eur6: raw.Eur6,
|
Eur6: raw.Eur6,
|
||||||
EurWholesaleStep: raw.EurWholesaleStep,
|
EurWholesaleStep: raw.EurWholesaleStep,
|
||||||
EurRetailStep: raw.EurRetailStep,
|
EurRetailStep: raw.EurRetailStep,
|
||||||
|
EurRetailMode: queries.NormalizeRetailModeForRoute(raw.EurRetailMode),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "pricing rule import error", http.StatusInternalServerError)
|
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)
|
||||||
}
|
}
|
||||||
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)
|
vi := pricingRuleStringValue(li, sortBy)
|
||||||
vj := pricingRuleStringValue(lj, sortBy)
|
vj := pricingRuleStringValue(lj, sortBy)
|
||||||
if desc {
|
if desc {
|
||||||
@@ -515,6 +717,26 @@ func pricingRuleStringValue(row queries.PricingParameterRuleRow, field string) s
|
|||||||
return row.BrandCode
|
return row.BrandCode
|
||||||
case "brand_group":
|
case "brand_group":
|
||||||
return row.BrandGroupSec
|
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:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -523,10 +745,10 @@ func pricingRuleStringValue(row queries.PricingParameterRuleRow, field string) s
|
|||||||
func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
|
func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
|
||||||
headers := []string{
|
headers := []string{
|
||||||
"DURUM", "AKTIF", "ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU",
|
"DURUM", "AKTIF", "ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU",
|
||||||
"ICERIK", "MARKA", "BRAND CODE", "MARKA GRUBU",
|
"ICERIK", "MARKA", "BRAND CODE", "MARKA GRUBU", "ANCHOR MODE", "HESAP AKTIF", "PG YAYIN", "NEBIM YAYIN",
|
||||||
"TRY TOPTAN YUVARLAMA", "TRY PERAKENDE YUVARLAMA", "TRY TABAN", "TRY 1", "TRY 2", "TRY 3", "TRY 4", "TRY 5", "TRY 6",
|
"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 YUVARLAMA", "USD TABAN", "USD 1", "USD 2", "USD 3", "USD 4", "USD 5", "USD 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 YUVARLAMA", "EUR TABAN", "EUR 1", "EUR 2", "EUR 3", "EUR 4", "EUR 5", "EUR 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
|
var b strings.Builder
|
||||||
for i, h := range headers {
|
for i, h := range headers {
|
||||||
@@ -551,10 +773,15 @@ func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
|
|||||||
row.UrunAnaGrubu,
|
row.UrunAnaGrubu,
|
||||||
row.UrunAltGrubu,
|
row.UrunAltGrubu,
|
||||||
row.Icerik,
|
row.Icerik,
|
||||||
row.Marka,
|
csvExcelTextValue(row.Marka),
|
||||||
csvExcelTextValue(row.BrandCode),
|
csvExcelTextValue(row.BrandCode),
|
||||||
row.BrandGroupSec,
|
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")),
|
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_retail_step")),
|
||||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_base")),
|
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_base")),
|
||||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try1")),
|
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, "try5")),
|
||||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try6")),
|
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try6")),
|
||||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_wholesale_step")),
|
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_retail_step")),
|
||||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_base")),
|
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_base")),
|
||||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd1")),
|
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, "usd5")),
|
||||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd6")),
|
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd6")),
|
||||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_wholesale_step")),
|
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_retail_step")),
|
||||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_base")),
|
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_base")),
|
||||||
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur1")),
|
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>
|
||||||
</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` }">
|
<div class="table-wrap" :style="{ '--sticky-scroll-comp': `${stickyScrollComp}px` }">
|
||||||
<q-table
|
<q-table
|
||||||
flat
|
flat
|
||||||
@@ -272,6 +281,34 @@
|
|||||||
dense
|
dense
|
||||||
@update:model-value="() => markDirty(props.row)"
|
@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
|
<input
|
||||||
v-else-if="numericFields.has(col.name)"
|
v-else-if="numericFields.has(col.name)"
|
||||||
class="native-cell-input text-right"
|
class="native-cell-input text-right"
|
||||||
@@ -287,6 +324,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<q-inner-loading :showing="saving" label="Kaydediliyor..." />
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -306,6 +345,7 @@ const fileInputRef = ref(null)
|
|||||||
const selectedKeyMap = ref({})
|
const selectedKeyMap = ref({})
|
||||||
const copySelectedKeys = ref([])
|
const copySelectedKeys = ref([])
|
||||||
const tablePagination = ref({ rowsPerPage: 0, sortBy: 'urun_ilk_grubu', descending: false })
|
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
|
let emptyRetryTimer = null
|
||||||
|
|
||||||
const numericFields = new Set([
|
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',
|
'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'
|
'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 = [
|
const importKeyFieldLabels = [
|
||||||
['askili_yan', 'ASKILI YAN'],
|
['askili_yan', 'ASKILI YAN'],
|
||||||
@@ -328,7 +370,12 @@ const importKeyFieldLabels = [
|
|||||||
|
|
||||||
const importFieldMap = {
|
const importFieldMap = {
|
||||||
AKTIF: 'is_active',
|
AKTIF: 'is_active',
|
||||||
|
'HESAP AKTIF': 'calc_enabled',
|
||||||
|
'PG YAYIN': 'publish_postgres',
|
||||||
|
'NEBIM YAYIN': 'publish_nebim',
|
||||||
'TRY TOPTAN YUVARLAMA': 'try_wholesale_step',
|
'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 PERAKENDE YUVARLAMA': 'try_retail_step',
|
||||||
'TRY YUVARLAMA': 'try_wholesale_step',
|
'TRY YUVARLAMA': 'try_wholesale_step',
|
||||||
'TRY TABAN': 'try_base',
|
'TRY TABAN': 'try_base',
|
||||||
@@ -339,6 +386,8 @@ const importFieldMap = {
|
|||||||
'TRY 5': 'try5',
|
'TRY 5': 'try5',
|
||||||
'TRY 6': 'try6',
|
'TRY 6': 'try6',
|
||||||
'USD TOPTAN YUVARLAMA': 'usd_wholesale_step',
|
'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 PERAKENDE YUVARLAMA': 'usd_retail_step',
|
||||||
'USD YUVARLAMA': 'usd_wholesale_step',
|
'USD YUVARLAMA': 'usd_wholesale_step',
|
||||||
'USD TABAN': 'usd_base',
|
'USD TABAN': 'usd_base',
|
||||||
@@ -349,6 +398,8 @@ const importFieldMap = {
|
|||||||
'USD 5': 'usd5',
|
'USD 5': 'usd5',
|
||||||
'USD 6': 'usd6',
|
'USD 6': 'usd6',
|
||||||
'EUR TOPTAN YUVARLAMA': 'eur_wholesale_step',
|
'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 PERAKENDE YUVARLAMA': 'eur_retail_step',
|
||||||
'EUR YUVARLAMA': 'eur_wholesale_step',
|
'EUR YUVARLAMA': 'eur_wholesale_step',
|
||||||
'EUR TABAN': 'eur_base',
|
'EUR TABAN': 'eur_base',
|
||||||
@@ -362,7 +413,8 @@ const importFieldMap = {
|
|||||||
|
|
||||||
const multiFilterFields = [
|
const multiFilterFields = [
|
||||||
'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu', 'urun_ana_grubu',
|
'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 multiSelectFilterFieldSet = new Set(multiFilterFields)
|
||||||
const numberRangeFilterFieldSet = new Set(numericFields)
|
const numberRangeFilterFieldSet = new Set(numericFields)
|
||||||
@@ -387,56 +439,64 @@ function col (name, label, field, width, extra = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
col('copy_select', 'KOPYA', 'copy_select', 86, { sortable: false, classes: 'copy-selection-col', headerClasses: 'copy-selection-col' }),
|
col('copy_select', 'KOPYA', 'copy_select', 68, { 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('select', 'KAYDET', 'select', 58, { sortable: false, classes: 'save-selection-col', headerClasses: 'save-selection-col' }),
|
||||||
col('has_rule', 'DURUM', 'has_rule', 62, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
col('has_rule', 'DURUM', 'has_rule', 54, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||||
col('is_active', 'AKTIF', 'is_active', 48, { 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', 86, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
col('askili_yan', 'ASKILI YAN', 'askili_yan', 72, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||||
col('kategori', 'KATEGORI', 'kategori', 92, { 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', 100, { 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', 110, { 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', 110, { 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', 90, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
col('icerik', 'ICERIK', 'icerik', 72, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||||
col('marka', 'MARKA', 'marka', 100, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
col('marka', 'MARKA', 'marka', 80, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||||
col('brand_code', 'BRAND CODE', 'brand_code', 78, { 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', 88, { 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_wholesale_step', 'TRY TOPTAN YUVARLAMA', 'try_wholesale_step', 76, { 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_retail_mode', 'TRY PERAKENDE MODU', 'try_retail_mode', 76, { classes: 'try-col', headerClasses: 'try-col' }),
|
||||||
col('try_base', 'TRY TABAN', 'try_base', 70, { align: 'right', 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('try1', 'TRY 1', 'try1', 62, { 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('try2', 'TRY 2', 'try2', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
col('try1', 'TRY 1', 'try1', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||||
col('try3', 'TRY 3', 'try3', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
col('try2', 'TRY 2', 'try2', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||||
col('try4', 'TRY 4', 'try4', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
col('try3', 'TRY 3', 'try3', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||||
col('try5', 'TRY 5', 'try5', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
col('try4', 'TRY 4', 'try4', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||||
col('try6', 'TRY 6', 'try6', 62, { 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_wholesale_step', 'USD TOPTAN YUVARLAMA', 'usd_wholesale_step', 76, { 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_retail_mode', 'USD PERAKENDE MODU', 'usd_retail_mode', 76, { classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||||
col('usd_base', 'USD TABAN', 'usd_base', 70, { align: 'right', 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('usd1', 'USD 1', 'usd1', 62, { 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('usd2', 'USD 2', 'usd2', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
col('usd1', 'USD 1', 'usd1', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||||
col('usd3', 'USD 3', 'usd3', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
col('usd2', 'USD 2', 'usd2', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||||
col('usd4', 'USD 4', 'usd4', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
col('usd3', 'USD 3', 'usd3', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||||
col('usd5', 'USD 5', 'usd5', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
col('usd4', 'USD 4', 'usd4', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||||
col('usd6', 'USD 6', 'usd6', 62, { 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_wholesale_step', 'EUR TOPTAN YUVARLAMA', 'eur_wholesale_step', 76, { 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_retail_mode', 'EUR PERAKENDE MODU', 'eur_retail_mode', 76, { classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||||
col('eur_base', 'EUR TABAN', 'eur_base', 70, { align: 'right', 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('eur1', 'EUR 1', 'eur1', 62, { 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('eur2', 'EUR 2', 'eur2', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
col('eur1', 'EUR 1', 'eur1', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||||
col('eur3', 'EUR 3', 'eur3', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
col('eur2', 'EUR 2', 'eur2', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||||
col('eur4', 'EUR 4', 'eur4', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
col('eur3', 'EUR 3', 'eur3', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||||
col('eur5', 'EUR 5', 'eur5', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
col('eur4', 'EUR 4', 'eur4', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||||
col('eur6', 'EUR 6', 'eur6', 62, { 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 = [
|
const stickyColumnNames = [
|
||||||
'copy_select', 'select', 'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu',
|
'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 stickyColumnNameSet = new Set(stickyColumnNames)
|
||||||
|
|
||||||
const stickyLeftMap = computed(() => {
|
const stickyLeftMap = computed(() => {
|
||||||
@@ -466,6 +526,7 @@ const tableStyle = computed(() => ({
|
|||||||
function filterDisplayValue (row, field) {
|
function filterDisplayValue (row, field) {
|
||||||
if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni'
|
if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni'
|
||||||
if (field === 'is_active') return row?.is_active ? 'Aktif' : 'Pasif'
|
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()
|
return String(row?.[field] ?? '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +623,10 @@ function normalizeWorksheetRow (source) {
|
|||||||
_row_key: String(source?.scope_key || source?.pricing_parameter_id || ''),
|
_row_key: String(source?.scope_key || source?.pricing_parameter_id || ''),
|
||||||
has_rule: Boolean(source?.has_rule),
|
has_rule: Boolean(source?.has_rule),
|
||||||
id: String(rule?.id || ''),
|
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,
|
is_active: rule?.is_active !== false,
|
||||||
askili_yan: String(source?.askili_yan || ''),
|
askili_yan: String(source?.askili_yan || ''),
|
||||||
kategori: String(source?.kategori || ''),
|
kategori: String(source?.kategori || ''),
|
||||||
@@ -572,6 +637,9 @@ function normalizeWorksheetRow (source) {
|
|||||||
marka: String(source?.marka || ''),
|
marka: String(source?.marka || ''),
|
||||||
brand_code: String(source?.brand_code || ''),
|
brand_code: String(source?.brand_code || ''),
|
||||||
brand_group: String(source?.brand_group || ''),
|
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
|
_dirty: false
|
||||||
}
|
}
|
||||||
for (const key of numericFields) {
|
for (const key of numericFields) {
|
||||||
@@ -607,6 +675,11 @@ function isRowSelected (row) {
|
|||||||
return !!selectedKeyMap.value?.[row._row_key]
|
return !!selectedKeyMap.value?.[row._row_key]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSelections () {
|
||||||
|
selectedKeyMap.value = {}
|
||||||
|
copySelectedKeys.value = []
|
||||||
|
}
|
||||||
|
|
||||||
function isCopySelected (row) {
|
function isCopySelected (row) {
|
||||||
return copySelectedKeySet.value.has(row._row_key)
|
return copySelectedKeySet.value.has(row._row_key)
|
||||||
}
|
}
|
||||||
@@ -668,9 +741,17 @@ function updateNumber (row, field, value) {
|
|||||||
markDirty(row)
|
markDirty(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateRetailMode (row, field, value) {
|
||||||
|
row[field] = retailModeOptions.includes(String(value || '').trim()) ? String(value).trim() : 'STEP'
|
||||||
|
markDirty(row)
|
||||||
|
}
|
||||||
|
|
||||||
function exportSortValue (row, field) {
|
function exportSortValue (row, field) {
|
||||||
if (field === 'has_rule') return row?.has_rule ? 1 : 0
|
if (field === 'has_rule') return row?.has_rule ? 1 : 0
|
||||||
if (field === 'is_active') return row?.is_active ? 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)
|
if (numericFields.has(field)) return finiteNumber(row?.[field], 0)
|
||||||
return String(row?.[field] ?? '')
|
return String(row?.[field] ?? '')
|
||||||
}
|
}
|
||||||
@@ -678,6 +759,16 @@ function exportSortValue (row, field) {
|
|||||||
function exportCellValue (row, field) {
|
function exportCellValue (row, field) {
|
||||||
if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni'
|
if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni'
|
||||||
if (field === 'is_active') return row?.is_active ? 'Aktif' : 'Pasif'
|
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)) {
|
if (numericFields.has(field)) {
|
||||||
const value = row?.[field]
|
const value = row?.[field]
|
||||||
if (value === '' || value === null || value === undefined) return '0'
|
if (value === '' || value === null || value === undefined) return '0'
|
||||||
@@ -858,10 +949,17 @@ async function onImportFileChange (event) {
|
|||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
try {
|
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 text = await file.text()
|
||||||
const matrix = parseCsvRows(text).filter(row => row.some(cell => String(cell || '').trim() !== ''))
|
const matrix = parseCsvRows(text).filter(row => row.some(cell => String(cell || '').trim() !== ''))
|
||||||
if (matrix.length < 2) {
|
if (matrix.length < 2) {
|
||||||
Notify.create({ type: 'negative', message: 'CSV bos veya gecersiz' })
|
Notify.create({ type: 'negative', message: 'CSV bos veya gecersiz' })
|
||||||
|
csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: 'CSV bos veya gecersiz.' }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,6 +981,7 @@ async function onImportFileChange (event) {
|
|||||||
let matched = 0
|
let matched = 0
|
||||||
let updated = 0
|
let updated = 0
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
|
const updatedRowKeys = []
|
||||||
|
|
||||||
for (let i = 1; i < matrix.length; i++) {
|
for (let i = 1; i < matrix.length; i++) {
|
||||||
const csvRow = matrix[i]
|
const csvRow = matrix[i]
|
||||||
@@ -912,7 +1011,22 @@ async function onImportFileChange (event) {
|
|||||||
}
|
}
|
||||||
continue
|
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)
|
const next = parseImportedNumber(rawValue)
|
||||||
if (Number(target[field] ?? 0) !== next) {
|
if (Number(target[field] ?? 0) !== next) {
|
||||||
target[field] = next
|
target[field] = next
|
||||||
@@ -922,21 +1036,48 @@ async function onImportFileChange (event) {
|
|||||||
|
|
||||||
if (rowChanged) {
|
if (rowChanged) {
|
||||||
markDirty(target)
|
markDirty(target)
|
||||||
|
updatedRowKeys.push(String(target._row_key || '').trim())
|
||||||
updated++
|
updated++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matched === 0) {
|
if (matched === 0) {
|
||||||
Notify.create({ type: 'warning', message: 'CSV icindeki satirlar ekrandaki kayitlarla eslesmedi' })
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Notify.create({
|
// Ensure: CSV'den degisen satirlar hem dirty hem de "Kaydet" secimi (checkbox) olarak isaretlensin.
|
||||||
type: 'positive',
|
// Bazı render edge-case'lerinde sadece sayac artip checkbox guncellenmiyor gibi gorunebiliyor;
|
||||||
message: `CSV yuklendi. Islenen: ${matrix.length - 1}, eslesen: ${matched}, guncellenen: ${updated}, atlanan: ${skipped}`
|
// 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) {
|
} catch (err) {
|
||||||
Notify.create({ type: 'negative', message: err?.message || 'CSV okunamadi' })
|
Notify.create({ type: 'negative', message: err?.message || 'CSV okunamadi' })
|
||||||
|
csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: err?.message || 'CSV okunamadi' }
|
||||||
} finally {
|
} finally {
|
||||||
if (input) input.value = ''
|
if (input) input.value = ''
|
||||||
}
|
}
|
||||||
@@ -953,6 +1094,12 @@ function copySelectedToSelected () {
|
|||||||
const target = rows.value.find(row => row._row_key === keys[i])
|
const target = rows.value.find(row => row._row_key === keys[i])
|
||||||
if (!target) continue
|
if (!target) continue
|
||||||
target.is_active = Boolean(source.is_active)
|
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) {
|
for (const field of numericFields) {
|
||||||
target[field] = source[field]
|
target[field] = source[field]
|
||||||
}
|
}
|
||||||
@@ -1056,8 +1203,7 @@ async function refreshRows () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearAllFilters()
|
clearAllFilters()
|
||||||
selectedKeyMap.value = {}
|
clearSelections()
|
||||||
copySelectedKeys.value = []
|
|
||||||
await loadRows()
|
await loadRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1067,6 +1213,7 @@ async function loadRows () {
|
|||||||
emptyRetryTimer = null
|
emptyRetryTimer = null
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
let ok = false
|
||||||
try {
|
try {
|
||||||
const res = await api.request({
|
const res = await api.request({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -1074,16 +1221,17 @@ async function loadRows () {
|
|||||||
timeout: 180000
|
timeout: 180000
|
||||||
})
|
})
|
||||||
rows.value = (Array.isArray(res?.data) ? res.data : []).map(normalizeWorksheetRow)
|
rows.value = (Array.isArray(res?.data) ? res.data : []).map(normalizeWorksheetRow)
|
||||||
selectedKeyMap.value = {}
|
clearSelections()
|
||||||
copySelectedKeys.value = []
|
|
||||||
if (rows.value.length === 0) {
|
if (rows.value.length === 0) {
|
||||||
emptyRetryTimer = setTimeout(loadRows, 10000)
|
emptyRetryTimer = setTimeout(loadRows, 10000)
|
||||||
}
|
}
|
||||||
|
ok = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kural kombinasyonlari alinamadi' })
|
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kural kombinasyonlari alinamadi' })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSelected () {
|
async function saveSelected () {
|
||||||
@@ -1091,27 +1239,124 @@ async function saveSelected () {
|
|||||||
if (dirty.length === 0) return
|
if (dirty.length === 0) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const startedAt = Date.now()
|
||||||
items: dirty.map(row => {
|
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 = {
|
const item = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
pricing_parameter_id: row.pricing_parameter_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)
|
for (const key of numericFields) item[key] = finiteNumber(row[key], 0)
|
||||||
return item
|
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({
|
await api.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/pricing/pricing-rules/bulk-save',
|
url: '/pricing/pricing-rules/bulk-save',
|
||||||
data: payload,
|
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}` })
|
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) {
|
} catch (err) {
|
||||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kurallar kaydedilemedi' })
|
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 {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -1119,8 +1364,7 @@ async function saveSelected () {
|
|||||||
|
|
||||||
function resetTransientState () {
|
function resetTransientState () {
|
||||||
rows.value = []
|
rows.value = []
|
||||||
selectedKeyMap.value = {}
|
clearSelections()
|
||||||
copySelectedKeys.value = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(refreshRows)
|
onMounted(refreshRows)
|
||||||
@@ -1133,10 +1377,11 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pricing-rules-page {
|
.pricing-rules-page {
|
||||||
--rules-row-height: 31px;
|
--rules-row-height: 27px;
|
||||||
--rules-header-height: 72px;
|
--rules-header-height: 58px;
|
||||||
--rules-table-height: calc(100vh - 210px);
|
--rules-table-height: calc(100vh - 210px);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: calc(100vh - 120px);
|
height: calc(100vh - 120px);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1181,7 +1426,7 @@ onBeforeUnmount(() => {
|
|||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
margin-right: var(--sticky-scroll-comp, 0px);
|
margin-right: var(--sticky-scroll-comp, 0px);
|
||||||
@@ -1197,7 +1442,7 @@ onBeforeUnmount(() => {
|
|||||||
.rules-table :deep(th),
|
.rules-table :deep(th),
|
||||||
.rules-table :deep(td) {
|
.rules-table :deep(td) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 4px;
|
padding: 0 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
@@ -1229,7 +1474,7 @@ onBeforeUnmount(() => {
|
|||||||
word-break: normal;
|
word-break: normal;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
@@ -1301,7 +1546,7 @@ onBeforeUnmount(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #bf5b04;
|
color: #bf5b04;
|
||||||
}
|
}
|
||||||
@@ -1375,7 +1620,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.rules-table :deep(.selection-col .q-checkbox__inner) {
|
.rules-table :deep(.selection-col .q-checkbox__inner) {
|
||||||
color: var(--q-primary);
|
color: var(--q-primary);
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-cell-wrap {
|
.copy-cell-wrap {
|
||||||
@@ -1389,7 +1634,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rules-table :deep(.rule-select-checkbox .q-checkbox__inner) {
|
.rules-table :deep(.rule-select-checkbox .q-checkbox__inner) {
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rules-table :deep(th.usd-col),
|
.rules-table :deep(th.usd-col),
|
||||||
@@ -1419,18 +1664,19 @@ onBeforeUnmount(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
padding: 0 4px;
|
padding: 0 3px;
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.native-cell-input {
|
.native-cell-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 22px;
|
height: 20px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 1px 3px;
|
padding: 1px 2px;
|
||||||
border: 1px solid #cfd8dc;
|
border: 1px solid #cfd8dc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1440,7 +1686,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-legend :deep(.q-chip) {
|
.action-legend :deep(.q-chip) {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="top-bar row items-center justify-between q-mb-xs">
|
<div class="top-bar row items-center justify-between q-mb-xs">
|
||||||
<div class="text-subtitle1 text-weight-bold">Urun Fiyatlandirma</div>
|
<div class="text-subtitle1 text-weight-bold">Urun Fiyatlandirma</div>
|
||||||
<div class="top-actions">
|
<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
|
<q-select
|
||||||
v-model="topUrunIlkGrubu"
|
v-model="topUrunIlkGrubu"
|
||||||
dense
|
dense
|
||||||
@@ -51,8 +51,17 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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--actions">
|
||||||
<q-btn-dropdown color="secondary" outline icon="view_module" label="Doviz Gorunumu" :auto-close="false">
|
<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-list dense class="currency-menu-list">
|
||||||
<q-item clickable @click="selectAllCurrencies">
|
<q-item clickable @click="selectAllCurrencies">
|
||||||
<q-item-section>Tumunu Sec</q-item-section>
|
<q-item-section>Tumunu Sec</q-item-section>
|
||||||
@@ -74,7 +83,11 @@
|
|||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-btn-dropdown>
|
</q-btn-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-group">
|
||||||
<q-btn
|
<q-btn
|
||||||
|
dense
|
||||||
flat
|
flat
|
||||||
:color="showSelectedOnly ? 'primary' : 'grey-7'"
|
:color="showSelectedOnly ? 'primary' : 'grey-7'"
|
||||||
:icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'"
|
:icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'"
|
||||||
@@ -83,30 +96,49 @@
|
|||||||
@click="toggleShowSelectedOnly"
|
@click="toggleShowSelectedOnly"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
dense
|
||||||
color="primary"
|
color="primary"
|
||||||
outline
|
outline
|
||||||
icon="edit_note"
|
icon="calculate"
|
||||||
label="Secili Olanlari Toplu Degistir"
|
label="Secilileri Hesapla"
|
||||||
:disable="selectedRowCount === 0"
|
:disable="selectedRowCount === 0 || bulkCalcLoading"
|
||||||
@click="bulkDialogOpen = true"
|
:loading="bulkCalcLoading"
|
||||||
|
@click="calculateSelectedRows"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
dense
|
||||||
color="primary"
|
color="primary"
|
||||||
flat
|
icon="save"
|
||||||
icon="download"
|
:label="saveButtonLabel"
|
||||||
label="Sayfayi Excel'e Aktar"
|
:disable="selectedDirtyCount === 0 || saving"
|
||||||
:disable="filteredRows.length === 0"
|
:loading="saving"
|
||||||
@click="exportCurrentView"
|
@click="saveSelectedRows"
|
||||||
/>
|
|
||||||
<q-btn
|
|
||||||
color="primary"
|
|
||||||
outline
|
|
||||||
icon="download_for_offline"
|
|
||||||
label="Tum Filtreyi Excel'e Aktar"
|
|
||||||
:disable="filteredRows.length === 0 || exportAllLoading"
|
|
||||||
:loading="exportAllLoading"
|
|
||||||
@click="exportAllFiltered"
|
|
||||||
/>
|
/>
|
||||||
|
</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
|
<q-pagination
|
||||||
v-model="currentPage"
|
v-model="currentPage"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -122,8 +154,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap" :style="{ '--sticky-scroll-comp': `${stickyScrollComp}px` }">
|
<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
|
<q-table
|
||||||
ref="mainTableRef"
|
ref="mainTableRef"
|
||||||
class="pane-table pricing-table"
|
class="pane-table pricing-table"
|
||||||
@@ -157,7 +212,15 @@
|
|||||||
@update:model-value="toggleSelectAllVisible"
|
@update:model-value="toggleSelectAllVisible"
|
||||||
/>
|
/>
|
||||||
<div v-else class="header-with-filter">
|
<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
|
<q-btn
|
||||||
v-if="isHeaderFilterField(col.field)"
|
v-if="isHeaderFilterField(col.field)"
|
||||||
dense
|
dense
|
||||||
@@ -350,7 +413,7 @@
|
|||||||
<q-checkbox
|
<q-checkbox
|
||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
:model-value="isRowSelected(props.row.productCode)"
|
:model-value="isRowSelected(rowSelectionKey(props.row))"
|
||||||
@update:model-value="(val) => onRowCheckboxChange(props.row, val)"
|
@update:model-value="(val) => onRowCheckboxChange(props.row, val)"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
@@ -368,15 +431,41 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
label="Hesapla"
|
label="Hesapla"
|
||||||
|
:loading="!!calcLoadingMap[props.row.productCode]"
|
||||||
@click="calculateRow(props.row)"
|
@click="calculateRow(props.row)"
|
||||||
/>
|
/>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</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">
|
<template #body-cell-productCode="props">
|
||||||
<q-td
|
<q-td
|
||||||
:props="props"
|
: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)"
|
:style="getBodyCellStyle(props.col)"
|
||||||
>
|
>
|
||||||
<span class="product-code-text" :title="String(props.value ?? '')">{{ props.value }}</span>
|
<span class="product-code-text" :title="String(props.value ?? '')">{{ props.value }}</span>
|
||||||
@@ -386,7 +475,11 @@
|
|||||||
<template #body-cell-stockQty="props">
|
<template #body-cell-stockQty="props">
|
||||||
<q-td
|
<q-td
|
||||||
:props="props"
|
: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)"
|
:style="getBodyCellStyle(props.col)"
|
||||||
>
|
>
|
||||||
<span class="stock-qty-text">{{ formatStock(props.value) }}</span>
|
<span class="stock-qty-text">{{ formatStock(props.value) }}</span>
|
||||||
@@ -396,7 +489,11 @@
|
|||||||
<template #body-cell-stockEntryDate="props">
|
<template #body-cell-stockEntryDate="props">
|
||||||
<q-td
|
<q-td
|
||||||
:props="props"
|
: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)"
|
:style="getBodyCellStyle(props.col)"
|
||||||
>
|
>
|
||||||
<span class="date-cell-text">{{ formatDateDisplay(props.value) }}</span>
|
<span class="date-cell-text">{{ formatDateDisplay(props.value) }}</span>
|
||||||
@@ -408,6 +505,7 @@
|
|||||||
:props="props"
|
:props="props"
|
||||||
:class="[
|
:class="[
|
||||||
{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) },
|
{ '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) }
|
{ 'cell-danger': needsCosting(props.row) }
|
||||||
]"
|
]"
|
||||||
:style="getBodyCellStyle(props.col)"
|
:style="getBodyCellStyle(props.col)"
|
||||||
@@ -424,7 +522,11 @@
|
|||||||
<template #body-cell-lastPricingDate="props">
|
<template #body-cell-lastPricingDate="props">
|
||||||
<q-td
|
<q-td
|
||||||
:props="props"
|
: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)"
|
:style="getBodyCellStyle(props.col)"
|
||||||
>
|
>
|
||||||
<span :class="['date-cell-text', { 'date-warning': needsRepricing(props.row) }]">
|
<span :class="['date-cell-text', { 'date-warning': needsRepricing(props.row) }]">
|
||||||
@@ -436,7 +538,11 @@
|
|||||||
<template #body-cell-brandGroupSelection="props">
|
<template #body-cell-brandGroupSelection="props">
|
||||||
<q-td
|
<q-td
|
||||||
:props="props"
|
: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)"
|
:style="getBodyCellStyle(props.col)"
|
||||||
>
|
>
|
||||||
<span class="cell-text" :title="props.row.brandGroupSelection || ''">
|
<span class="cell-text" :title="props.row.brandGroupSelection || ''">
|
||||||
@@ -448,75 +554,293 @@
|
|||||||
<template #body-cell="props">
|
<template #body-cell="props">
|
||||||
<q-td
|
<q-td
|
||||||
:props="props"
|
: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)"
|
:style="getBodyCellStyle(props.col)"
|
||||||
>
|
>
|
||||||
|
<div v-if="editableColumnSet.has(props.col.name)" class="editable-price-cell">
|
||||||
<input
|
<input
|
||||||
v-if="editableColumnSet.has(props.col.name)"
|
class="native-cell-input text-right price-edit-input"
|
||||||
class="native-cell-input text-right"
|
|
||||||
:value="formatPrice(props.row[props.col.field])"
|
:value="formatPrice(props.row[props.col.field])"
|
||||||
type="text"
|
type="text"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
@change="(e) => onEditableCellChange(props.row, props.col.field, e.target.value)"
|
@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>
|
<span v-else class="cell-text" :title="String(props.value ?? '')">{{ props.value }}</span>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-banner v-if="store.error" class="bg-red text-white q-mt-xs">
|
<q-banner v-if="store.error && !isGuidanceState" class="bg-red text-white q-mt-xs">
|
||||||
Hata: {{ store.error }}
|
{{ store.error }}
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-dialog v-model="bulkDialogOpen">
|
<q-dialog v-model="priceHistoryDialogOpen" persistent>
|
||||||
<q-card style="min-width: 420px; max-width: 95vw;">
|
<q-card class="price-history-card">
|
||||||
<q-card-section class="text-subtitle1 text-weight-bold">
|
<q-card-section class="row items-center justify-between">
|
||||||
Secili Olanlari Toplu Degistir
|
<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>
|
||||||
<q-card-section class="q-gutter-sm">
|
|
||||||
<q-select
|
<q-separator />
|
||||||
v-model="bulkField"
|
|
||||||
:options="bulkFieldOptions"
|
<q-card-section class="q-pt-sm q-pb-none">
|
||||||
option-value="value"
|
<div class="row items-center q-gutter-sm">
|
||||||
option-label="label"
|
<q-btn
|
||||||
emit-value
|
color="negative"
|
||||||
map-options
|
icon="delete"
|
||||||
dense
|
label="Secilenleri Sil"
|
||||||
outlined
|
:disable="selectedHistoryCount === 0 || priceHistoryLoading"
|
||||||
label="Alan"
|
@click="confirmDeleteSelectedHistory"
|
||||||
/>
|
/>
|
||||||
<q-input
|
<q-space />
|
||||||
v-model="bulkValue"
|
<q-btn
|
||||||
dense
|
outline
|
||||||
outlined
|
color="primary"
|
||||||
label="Deger"
|
icon="refresh"
|
||||||
inputmode="decimal"
|
label="Yenile"
|
||||||
|
:loading="priceHistoryLoading"
|
||||||
|
:disable="!priceHistoryRow?.productCode"
|
||||||
|
@click="reloadPriceHistory()"
|
||||||
/>
|
/>
|
||||||
<div class="text-caption text-grey-8">
|
|
||||||
Uygulanacak satir sayisi: {{ selectedRowCount }}
|
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right">
|
|
||||||
<q-btn flat label="Iptal" v-close-popup />
|
<q-card-section class="q-pt-sm">
|
||||||
<q-btn color="primary" label="Uygula" @click="applyBulkUpdate" />
|
<q-inner-loading :showing="priceHistoryLoading">
|
||||||
</q-card-actions>
|
<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-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { Notify } from 'quasar'
|
import { Notify, useQuasar } from 'quasar'
|
||||||
import { useProductPricingStore } from 'src/stores/ProductPricingStore'
|
import { useProductPricingStore } from 'src/stores/ProductPricingStore'
|
||||||
import api, { download } from 'src/services/api'
|
import api, { download } from 'src/services/api'
|
||||||
|
|
||||||
|
const $q = useQuasar()
|
||||||
const store = useProductPricingStore()
|
const store = useProductPricingStore()
|
||||||
const PAGE_LIMIT = 250
|
const PAGE_LIMIT = 250
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
let reloadTimer = null
|
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 usdToTry = 38.25
|
||||||
const eurToTry = 41.6
|
const eurToTry = 41.6
|
||||||
const multipliers = [1, 1.03, 1.06, 1.09, 1.12, 1.15]
|
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 dateRangeFilterFields = ['stockEntryDate', 'lastPricingDate']
|
||||||
const valueFilterFields = [
|
const valueFilterFields = [
|
||||||
'costPrice',
|
'costPrice',
|
||||||
'expenseForBasePrice',
|
|
||||||
'basePriceUsd',
|
'basePriceUsd',
|
||||||
'basePriceTry',
|
'basePriceTry',
|
||||||
'usd1',
|
'usd1',
|
||||||
@@ -615,6 +938,32 @@ const topUrunAnaGrubu = ref([])
|
|||||||
|
|
||||||
const topUrunIlkGrubuOptions = computed(() => serverFilterOptionMap.value.urunIlkGrubu || [])
|
const topUrunIlkGrubuOptions = computed(() => serverFilterOptionMap.value.urunIlkGrubu || [])
|
||||||
const topUrunAnaGrubuOptions = computed(() => serverFilterOptionMap.value.urunAnaGrubu || [])
|
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(() => {
|
const canFetchByGroup = computed(() => {
|
||||||
return Boolean(String(topUrunIlkGrubu.value || '').trim()) || (topUrunAnaGrubu.value?.length || 0) > 0
|
return Boolean(String(topUrunIlkGrubu.value || '').trim()) || (topUrunAnaGrubu.value?.length || 0) > 0
|
||||||
})
|
})
|
||||||
@@ -709,13 +1058,107 @@ function onTopUrunAnaGrubuChange () {
|
|||||||
applyTopGroupFiltersToColumnFilters()
|
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 () {
|
function resetGroupSelections () {
|
||||||
topUrunIlkGrubu.value = null
|
topUrunIlkGrubu.value = null
|
||||||
topUrunAnaGrubu.value = []
|
topUrunAnaGrubu.value = []
|
||||||
applyTopGroupFiltersToColumnFilters()
|
applyTopGroupFiltersToColumnFilters()
|
||||||
// Keep other local filters cleared too, so page is "clean render".
|
// Keep other local filters cleared too, so page is "clean render".
|
||||||
store.rows = []
|
store.rows = []
|
||||||
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
|
store.error = GUIDANCE_MSG
|
||||||
store.totalCount = 0
|
store.totalCount = 0
|
||||||
store.totalPages = 1
|
store.totalPages = 1
|
||||||
store.page = 1
|
store.page = 1
|
||||||
@@ -749,6 +1192,8 @@ const headerFilterFieldSet = new Set([
|
|||||||
])
|
])
|
||||||
|
|
||||||
const mainTableRef = ref(null)
|
const mainTableRef = ref(null)
|
||||||
|
const topScrollRef = ref(null)
|
||||||
|
const topScrollInnerRef = ref(null)
|
||||||
const tablePagination = ref({
|
const tablePagination = ref({
|
||||||
page: 1, // server-side paging var; q-table local paging kapali
|
page: 1, // server-side paging var; q-table local paging kapali
|
||||||
rowsPerPage: 0,
|
rowsPerPage: 0,
|
||||||
@@ -756,16 +1201,37 @@ const tablePagination = ref({
|
|||||||
descending: true
|
descending: true
|
||||||
})
|
})
|
||||||
const selectedMap = ref({})
|
const selectedMap = ref({})
|
||||||
const bulkDialogOpen = ref(false)
|
|
||||||
const bulkField = ref('expenseForBasePrice')
|
|
||||||
const bulkValue = ref('')
|
|
||||||
const selectedCurrencies = ref(['USD', 'EUR', 'TRY'])
|
const selectedCurrencies = ref(['USD', 'EUR', 'TRY'])
|
||||||
const exportAllLoading = ref(false)
|
const exportAllLoading = ref(false)
|
||||||
const showSelectedOnly = 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 = [
|
const editableColumns = [
|
||||||
'costPrice',
|
'costPrice',
|
||||||
'expenseForBasePrice',
|
|
||||||
'basePriceUsd',
|
'basePriceUsd',
|
||||||
'basePriceTry',
|
'basePriceTry',
|
||||||
'usd1',
|
'usd1',
|
||||||
@@ -809,6 +1275,7 @@ const allColumns = [
|
|||||||
col('marka', 'MARKA', 'marka', 54, { sortable: true, classes: 'ps-col' }),
|
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('productCode', 'URUN KODU', 'productCode', 108, { sortable: true, classes: 'ps-col product-code-col' }),
|
||||||
col('calcAction', 'HESAPLA', 'calcAction', 72, { align: 'center', classes: 'ps-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('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('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' }),
|
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('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 66, { sortable: true, classes: 'ps-col' }),
|
||||||
col('icerik', 'ICERIK', 'icerik', 62, { 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('karisim', 'KARISIM', 'karisim', 62, { sortable: true, classes: 'ps-col' }),
|
||||||
col('costPrice', 'MALIYET FIYATI', 'costPrice', 74, { align: 'right', sortable: true, classes: 'usd-col' }),
|
col('costPrice', 'MALIYET FIYATI', 'costPrice', 88, { align: 'right', sortable: true, classes: 'usd-col' }),
|
||||||
col('expenseForBasePrice', 'TABAN FIYAT MASRAF', 'expenseForBasePrice', 86, { align: 'right', classes: 'usd-col' }),
|
col('basePriceUsd', 'TABAN USD', 'basePriceUsd', 88, { align: 'right', classes: 'usd-col' }),
|
||||||
col('basePriceUsd', 'TABAN USD', 'basePriceUsd', 74, { align: 'right', classes: 'usd-col' }),
|
col('basePriceTry', 'TABAN TRY', 'basePriceTry', 96, { align: 'right', classes: 'try-col' }),
|
||||||
col('basePriceTry', 'TABAN TRY', 'basePriceTry', 74, { align: 'right', classes: 'try-col' }),
|
col('usd1', 'USD 1', 'usd1', 84, { align: 'right', classes: 'usd-col' }),
|
||||||
col('usd1', 'USD 1', 'usd1', 62, { align: 'right', classes: 'usd-col' }),
|
col('usd2', 'USD 2', 'usd2', 84, { align: 'right', classes: 'usd-col' }),
|
||||||
col('usd2', 'USD 2', 'usd2', 62, { align: 'right', classes: 'usd-col' }),
|
col('usd3', 'USD 3', 'usd3', 84, { align: 'right', classes: 'usd-col' }),
|
||||||
col('usd3', 'USD 3', 'usd3', 62, { align: 'right', classes: 'usd-col' }),
|
col('usd4', 'USD 4', 'usd4', 84, { align: 'right', classes: 'usd-col' }),
|
||||||
col('usd4', 'USD 4', 'usd4', 62, { align: 'right', classes: 'usd-col' }),
|
col('usd5', 'USD 5', 'usd5', 84, { align: 'right', classes: 'usd-col' }),
|
||||||
col('usd5', 'USD 5', 'usd5', 62, { align: 'right', classes: 'usd-col' }),
|
col('usd6', 'USD 6', 'usd6', 84, { align: 'right', classes: 'usd-col' }),
|
||||||
col('usd6', 'USD 6', 'usd6', 62, { align: 'right', classes: 'usd-col' }),
|
col('eur1', 'EUR 1', 'eur1', 84, { align: 'right', classes: 'eur-col' }),
|
||||||
col('eur1', 'EUR 1', 'eur1', 62, { align: 'right', classes: 'eur-col' }),
|
col('eur2', 'EUR 2', 'eur2', 84, { align: 'right', classes: 'eur-col' }),
|
||||||
col('eur2', 'EUR 2', 'eur2', 62, { align: 'right', classes: 'eur-col' }),
|
col('eur3', 'EUR 3', 'eur3', 84, { align: 'right', classes: 'eur-col' }),
|
||||||
col('eur3', 'EUR 3', 'eur3', 62, { align: 'right', classes: 'eur-col' }),
|
col('eur4', 'EUR 4', 'eur4', 84, { align: 'right', classes: 'eur-col' }),
|
||||||
col('eur4', 'EUR 4', 'eur4', 62, { align: 'right', classes: 'eur-col' }),
|
col('eur5', 'EUR 5', 'eur5', 84, { align: 'right', classes: 'eur-col' }),
|
||||||
col('eur5', 'EUR 5', 'eur5', 62, { align: 'right', classes: 'eur-col' }),
|
col('eur6', 'EUR 6', 'eur6', 84, { align: 'right', classes: 'eur-col' }),
|
||||||
col('eur6', 'EUR 6', 'eur6', 62, { align: 'right', classes: 'eur-col' }),
|
col('try1', 'TRY 1', 'try1', 96, { align: 'right', classes: 'try-col' }),
|
||||||
col('try1', 'TRY 1', 'try1', 62, { align: 'right', classes: 'try-col' }),
|
col('try2', 'TRY 2', 'try2', 96, { align: 'right', classes: 'try-col' }),
|
||||||
col('try2', 'TRY 2', 'try2', 62, { align: 'right', classes: 'try-col' }),
|
col('try3', 'TRY 3', 'try3', 96, { align: 'right', classes: 'try-col' }),
|
||||||
col('try3', 'TRY 3', 'try3', 62, { align: 'right', classes: 'try-col' }),
|
col('try4', 'TRY 4', 'try4', 96, { align: 'right', classes: 'try-col' }),
|
||||||
col('try4', 'TRY 4', 'try4', 62, { align: 'right', classes: 'try-col' }),
|
col('try5', 'TRY 5', 'try5', 96, { align: 'right', classes: 'try-col' }),
|
||||||
col('try5', 'TRY 5', 'try5', 62, { align: 'right', classes: 'try-col' }),
|
col('try6', 'TRY 6', 'try6', 96, { align: 'right', classes: 'try-col' })
|
||||||
col('try6', 'TRY 6', 'try6', 62, { align: 'right', classes: 'try-col' })
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const stickyColumnNames = [
|
const hideableLeftDetailColumnNames = new Set([
|
||||||
|
'stockEntryDate',
|
||||||
|
'lastCostingDate',
|
||||||
|
'lastPricingDate',
|
||||||
|
'askiliYan',
|
||||||
|
'kategori',
|
||||||
|
'urunIlkGrubu',
|
||||||
|
'urunAnaGrubu',
|
||||||
|
'urunAltGrubu',
|
||||||
|
'icerik',
|
||||||
|
'karisim'
|
||||||
|
])
|
||||||
|
const stickyColumnNamesBase = [
|
||||||
'select',
|
'select',
|
||||||
'brandGroupSelection',
|
'brandGroupSelection',
|
||||||
'marka',
|
'marka',
|
||||||
'productCode',
|
'productCode',
|
||||||
'calcAction',
|
'calcAction',
|
||||||
|
'historyAction',
|
||||||
'stockQty',
|
'stockQty',
|
||||||
'stockEntryDate',
|
'stockEntryDate',
|
||||||
'lastPricingDate',
|
'lastPricingDate',
|
||||||
@@ -861,12 +1340,10 @@ const stickyColumnNames = [
|
|||||||
'icerik',
|
'icerik',
|
||||||
'karisim',
|
'karisim',
|
||||||
'costPrice',
|
'costPrice',
|
||||||
'expenseForBasePrice',
|
|
||||||
'basePriceUsd',
|
'basePriceUsd',
|
||||||
'basePriceTry'
|
'basePriceTry'
|
||||||
]
|
]
|
||||||
const stickyBoundaryColumnName = 'basePriceTry'
|
const stickyBoundaryColumnName = 'basePriceTry'
|
||||||
const stickyColumnNameSet = new Set(stickyColumnNames)
|
|
||||||
|
|
||||||
const visibleColumns = computed(() => {
|
const visibleColumns = computed(() => {
|
||||||
const selected = new Set(selectedCurrencies.value)
|
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('usd')) return selected.has('USD')
|
||||||
if (c.name.startsWith('eur')) return selected.has('EUR')
|
if (c.name.startsWith('eur')) return selected.has('EUR')
|
||||||
if (c.name.startsWith('try')) return selected.has('TRY')
|
if (c.name.startsWith('try')) return selected.has('TRY')
|
||||||
|
if (!leftDetailsExpanded.value && hideableLeftDetailColumnNames.has(c.name)) return false
|
||||||
return true
|
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 stickyLeftMap = computed(() => {
|
||||||
const map = {}
|
const map = {}
|
||||||
let left = 0
|
let left = 0
|
||||||
for (const colName of stickyColumnNames) {
|
for (const colName of stickyColumnNames.value) {
|
||||||
const c = allColumns.find((x) => x.name === colName)
|
const c = allColumns.find((x) => x.name === colName)
|
||||||
if (!c) continue
|
if (!c) continue
|
||||||
map[colName] = left
|
map[colName] = left
|
||||||
@@ -893,9 +1415,8 @@ const stickyLeftMap = computed(() => {
|
|||||||
})
|
})
|
||||||
const stickyScrollComp = computed(() => {
|
const stickyScrollComp = computed(() => {
|
||||||
const boundaryCol = allColumns.find((x) => x.name === stickyBoundaryColumnName)
|
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 tableMinWidth = computed(() => visibleColumns.value.reduce((sum, c) => sum + extractWidth(c.style), 0))
|
||||||
const tableStyle = computed(() => ({
|
const tableStyle = computed(() => ({
|
||||||
width: `${tableMinWidth.value}px`,
|
width: `${tableMinWidth.value}px`,
|
||||||
@@ -905,16 +1426,9 @@ const tableStyle = computed(() => ({
|
|||||||
|
|
||||||
const rows = computed(() => store.rows || [])
|
const rows = computed(() => store.rows || [])
|
||||||
const tableLoading = computed(() => Boolean(store.loading) && rows.value.length === 0)
|
const tableLoading = computed(() => Boolean(store.loading) && rows.value.length === 0)
|
||||||
const bulkFieldOptions = computed(() => {
|
|
||||||
return editableColumns
|
const isGuidanceState = computed(() => String(store.error || '').trim() === GUIDANCE_MSG)
|
||||||
.map((name) => {
|
const showGuidanceOverlay = computed(() => isGuidanceState.value && !store.loading && rows.value.length === 0)
|
||||||
const colDef = allColumns.find((c) => c.field === name)
|
|
||||||
return {
|
|
||||||
value: name,
|
|
||||||
label: colDef?.label || name
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const multiFilterOptionMap = computed(() => {
|
const multiFilterOptionMap = computed(() => {
|
||||||
const map = {}
|
const map = {}
|
||||||
multiFilterColumns.forEach(({ field }) => {
|
multiFilterColumns.forEach(({ field }) => {
|
||||||
@@ -1136,7 +1650,7 @@ function extractWidth (style) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isStickyCol (colName) {
|
function isStickyCol (colName) {
|
||||||
return stickyColumnNameSet.has(colName)
|
return stickyColumnNameSet.value.has(colName)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStickyBoundary (colName) {
|
function isStickyBoundary (colName) {
|
||||||
@@ -1226,6 +1740,28 @@ function formatDateDisplay (val) {
|
|||||||
return `${day}.${month}.${year}`
|
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) {
|
function exportCellValue (row, field) {
|
||||||
if (field === 'stockQty') return formatStock(row?.[field])
|
if (field === 'stockQty') return formatStock(row?.[field])
|
||||||
if (field === 'stockEntryDate' || field === 'lastCostingDate' || field === 'lastPricingDate') return formatDateDisplay(row?.[field])
|
if (field === 'stockEntryDate' || field === 'lastCostingDate' || field === 'lastPricingDate') return formatDateDisplay(row?.[field])
|
||||||
@@ -1337,24 +1873,252 @@ function needsCosting (row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function recalcByBasePrice (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) => {
|
multipliers.forEach((multiplier, index) => {
|
||||||
row[`usd${index + 1}`] = round2(row.basePriceUsd * multiplier)
|
const nextUsd = round2(prevUsd * multiplier)
|
||||||
row[`eur${index + 1}`] = round2((row.basePriceUsd * usdToTry * multiplier) / eurToTry)
|
const nextTry = round2(prevTry * multiplier)
|
||||||
row[`try${index + 1}`] = round2(row.basePriceTry * 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) {
|
function onEditableCellChange (row, field, val) {
|
||||||
const parsed = parseNumber(val)
|
const parsed = parseNumber(val)
|
||||||
store.updateCell(row, field, parsed)
|
store.updateCell(row, field, parsed)
|
||||||
if (field === 'expenseForBasePrice' || field === 'basePriceUsd') recalcByBasePrice(row)
|
if (field === 'basePriceUsd') recalcByBasePrice(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateRow (row) {
|
function setCalcLoading (productCode, value) {
|
||||||
if (!row) return
|
calcLoadingMap.value = {
|
||||||
recalcByBasePrice(row)
|
...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)
|
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) {
|
function onBrandGroupSelectionChange (row, val) {
|
||||||
@@ -1362,7 +2126,35 @@ function onBrandGroupSelectionChange (row, val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isRowSelected (rowKey) {
|
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) {
|
function onRowCheckboxChange (row, val) {
|
||||||
@@ -1371,7 +2163,142 @@ function onRowCheckboxChange (row, val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleRowSelection (rowKey, 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) {
|
function toggleSelectAllVisible (val) {
|
||||||
@@ -1380,20 +2307,6 @@ function toggleSelectAllVisible (val) {
|
|||||||
selectedMap.value = next
|
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 () {
|
function resetAll () {
|
||||||
columnFilters.value = {
|
columnFilters.value = {
|
||||||
productCode: [],
|
productCode: [],
|
||||||
@@ -1500,7 +2413,7 @@ function scheduleReload () {
|
|||||||
}, 180)
|
}, 180)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChunk ({ page = 1 } = {}) {
|
async function fetchChunk ({ page = 1, useCache = true } = {}) {
|
||||||
const filters = buildServerFilters()
|
const filters = buildServerFilters()
|
||||||
const hasAnyFilter = Object.values(filters).some((v) => Array.isArray(v) && v.length > 0)
|
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
|
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) {
|
if (!hasPrimaryFilter) {
|
||||||
store.rows = []
|
store.rows = []
|
||||||
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
|
store.error = GUIDANCE_MSG
|
||||||
store.totalCount = 0
|
store.totalCount = 0
|
||||||
store.totalPages = 1
|
store.totalPages = 1
|
||||||
store.page = 1
|
store.page = 1
|
||||||
@@ -1528,6 +2441,7 @@ async function fetchChunk ({ page = 1 } = {}) {
|
|||||||
page,
|
page,
|
||||||
append: false,
|
append: false,
|
||||||
silent: false,
|
silent: false,
|
||||||
|
useCache,
|
||||||
filters,
|
filters,
|
||||||
sortBy: tablePagination.value.sortBy,
|
sortBy: tablePagination.value.sortBy,
|
||||||
descending: tablePagination.value.descending
|
descending: tablePagination.value.descending
|
||||||
@@ -1536,13 +2450,13 @@ async function fetchChunk ({ page = 1 } = {}) {
|
|||||||
return Number(result?.fetched) || 0
|
return Number(result?.fetched) || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reloadData ({ page = 1 } = {}) {
|
async function reloadData ({ page = 1, useCache = true } = {}) {
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
console.info('[product-pricing][ui] reload:start', {
|
console.info('[product-pricing][ui] reload:start', {
|
||||||
at: new Date(startedAt).toISOString()
|
at: new Date(startedAt).toISOString()
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
await fetchChunk({ page })
|
await fetchChunk({ page, useCache })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[product-pricing][ui] reload:error', {
|
console.error('[product-pricing][ui] reload:error', {
|
||||||
duration_ms: Date.now() - startedAt,
|
duration_ms: Date.now() - startedAt,
|
||||||
@@ -1554,6 +2468,7 @@ async function reloadData ({ page = 1 } = {}) {
|
|||||||
row_count: Array.isArray(store.rows) ? store.rows.length : 0,
|
row_count: Array.isArray(store.rows) ? store.rows.length : 0,
|
||||||
has_error: Boolean(store.error)
|
has_error: Boolean(store.error)
|
||||||
})
|
})
|
||||||
|
await bindHorizontalScrollSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full "fetch all pages" is intentionally avoided; keep server-side paging for performance.
|
// Full "fetch all pages" is intentionally avoided; keep server-side paging for performance.
|
||||||
@@ -1572,18 +2487,34 @@ onMounted(async () => {
|
|||||||
void fetchServerFilterOptions('urunAnaGrubu')
|
void fetchServerFilterOptions('urunAnaGrubu')
|
||||||
// Do not auto-fetch listing on mount; user must scope by group first.
|
// Do not auto-fetch listing on mount; user must scope by group first.
|
||||||
store.rows = []
|
store.rows = []
|
||||||
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
|
store.error = GUIDANCE_MSG
|
||||||
store.totalCount = 0
|
store.totalCount = 0
|
||||||
store.totalPages = 1
|
store.totalPages = 1
|
||||||
store.page = 1
|
store.page = 1
|
||||||
store.hasMore = false
|
store.hasMore = false
|
||||||
|
await bindHorizontalScrollSync()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [tableMinWidth.value, rows.value.length, selectedCurrencies.value.join(',')],
|
||||||
|
() => {
|
||||||
|
void bindHorizontalScrollSync()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (reloadTimer) {
|
if (reloadTimer) {
|
||||||
clearTimeout(reloadTimer)
|
clearTimeout(reloadTimer)
|
||||||
reloadTimer = null
|
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.
|
// NOTE: Listing fetch is intentionally manual via "Gruplari Getir" for performance.
|
||||||
@@ -1595,6 +2526,7 @@ onBeforeUnmount(() => {
|
|||||||
--pricing-header-height: 72px;
|
--pricing-header-height: 72px;
|
||||||
--pricing-table-height: calc(100vh - 210px);
|
--pricing-table-height: calc(100vh - 210px);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
height: calc(100vh - 120px);
|
height: calc(100vh - 120px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1608,16 +2540,96 @@ onBeforeUnmount(() => {
|
|||||||
.top-actions {
|
.top-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: stretch;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-actions-row {
|
.top-actions-row {
|
||||||
flex-wrap: wrap;
|
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 {
|
.table-wrap {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1627,15 +2639,65 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
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 {
|
.pane-table {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-table :deep(.q-table__middle) {
|
.pricing-table :deep(.q-table__middle) {
|
||||||
height: var(--pricing-table-height);
|
height: calc(var(--pricing-table-height) - 14px);
|
||||||
min-height: var(--pricing-table-height);
|
min-height: calc(var(--pricing-table-height) - 14px);
|
||||||
max-height: var(--pricing-table-height);
|
max-height: calc(var(--pricing-table-height) - 14px);
|
||||||
overflow: auto !important;
|
overflow: auto !important;
|
||||||
scrollbar-gutter: stable both-edges;
|
scrollbar-gutter: stable both-edges;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
@@ -1661,7 +2723,7 @@ onBeforeUnmount(() => {
|
|||||||
.pricing-table :deep(th),
|
.pricing-table :deep(th),
|
||||||
.pricing-table :deep(td) {
|
.pricing-table :deep(td) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 4px;
|
padding: 0 1px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
@@ -1681,7 +2743,7 @@ onBeforeUnmount(() => {
|
|||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
padding: 0 4px !important;
|
padding: 0 1px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-table :deep(th),
|
.pricing-table :deep(th),
|
||||||
@@ -1725,6 +2787,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.pricing-table :deep(tbody .sticky-col) {
|
.pricing-table :deep(tbody .sticky-col) {
|
||||||
z-index: 12 !important;
|
z-index: 12 !important;
|
||||||
|
background: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-table :deep(.sticky-boundary) {
|
.pricing-table :deep(.sticky-boundary) {
|
||||||
@@ -1732,6 +2795,20 @@ onBeforeUnmount(() => {
|
|||||||
box-shadow: 8px 0 12px -10px rgba(15, 23, 42, 0.55);
|
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 {
|
.header-with-filter {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 20px;
|
grid-template-columns: 1fr 20px;
|
||||||
@@ -1830,6 +2907,11 @@ onBeforeUnmount(() => {
|
|||||||
word-break: break-word;
|
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 {
|
.stock-qty-text {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1879,7 +2961,6 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pricing-table :deep(.selection-col .q-checkbox__bg) {
|
.pricing-table :deep(.selection-col .q-checkbox__bg) {
|
||||||
background: #fff;
|
|
||||||
border-color: var(--q-primary);
|
border-color: var(--q-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1922,17 +3003,49 @@ onBeforeUnmount(() => {
|
|||||||
letter-spacing: 0;
|
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-input,
|
||||||
.native-cell-select {
|
.native-cell-select {
|
||||||
width: 100%;
|
width: 90%;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 1px 3px;
|
padding: 1px 1px;
|
||||||
border: 1px solid #cfd8dc;
|
border: 1px solid #cfd8dc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
margin: 0;
|
margin: 0 auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-edit-input {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.native-cell-input:focus,
|
.native-cell-input:focus,
|
||||||
|
|||||||
@@ -382,6 +382,13 @@ const routes = [
|
|||||||
component: () => import('pages/BrandClassification.vue'),
|
component: () => import('pages/BrandClassification.vue'),
|
||||||
meta: { permission: 'pricing:view' }
|
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',
|
path: 'pricing/pricing-rules',
|
||||||
name: 'pricing-rules',
|
name: 'pricing-rules',
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ api.interceptors.request.use((config) => {
|
|||||||
trace_id: traceId,
|
trace_id: traceId,
|
||||||
method: String(config.method || 'GET').toUpperCase(),
|
method: String(config.method || 'GET').toUpperCase(),
|
||||||
url,
|
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) {
|
function mapRow (raw, index, baseIndex = 0) {
|
||||||
return {
|
const row = {
|
||||||
id: baseIndex + index + 1,
|
id: baseIndex + index + 1,
|
||||||
productCode: toText(raw?.ProductCode),
|
productCode: toText(raw?.ProductCode),
|
||||||
stockQty: toNumber(raw?.StockQty),
|
stockQty: toNumber(raw?.StockQty),
|
||||||
@@ -76,6 +76,18 @@ function mapRow (raw, index, baseIndex = 0) {
|
|||||||
try5: toNumber(raw?.TRY5),
|
try5: toNumber(raw?.TRY5),
|
||||||
try6: toNumber(raw?.TRY6)
|
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 = []) {
|
function cloneRows (rows = []) {
|
||||||
|
|||||||
Reference in New Issue
Block a user