Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-06-17 21:56:49 +03:00
parent e1e9d4baf1
commit e14c1c176a
34 changed files with 7402 additions and 704 deletions

View File

@@ -0,0 +1 @@
exit status 1

View 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

View 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?

View 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

View File

@@ -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(

View 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")
}
}()
}

View 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")
}
}()
}

View File

@@ -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

View 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[:])
}

View 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
}

View File

@@ -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()

View File

@@ -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,7 +128,12 @@ type PricingRuleRow struct {
BrandCode []string `json:"brand_code"` BrandCode []string `json:"brand_code"`
BrandGroupSec []string `json:"brand_group"` BrandGroupSec []string `json:"brand_group"`
IsActive bool `json:"is_active"` StrategyCode string `json:"strategy_code"`
AnchorMode string `json:"anchor_mode"`
CalcEnabled bool `json:"calc_enabled"`
PublishPostgres bool `json:"publish_postgres"`
PublishNebim bool `json:"publish_nebim"`
IsActive bool `json:"is_active"`
// multipliers/rolls are per currency // multipliers/rolls are per currency
TryBase float64 `json:"try_base"` TryBase float64 `json:"try_base"`
@@ -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,7 +185,12 @@ type PricingRuleSaveItem struct {
BrandCode []string `json:"brand_code"` BrandCode []string `json:"brand_code"`
BrandGroupSec []string `json:"brand_group"` BrandGroupSec []string `json:"brand_group"`
IsActive bool `json:"is_active"` StrategyCode string `json:"strategy_code"`
AnchorMode string `json:"anchor_mode"`
CalcEnabled bool `json:"calc_enabled"`
PublishPostgres bool `json:"publish_postgres"`
PublishNebim bool `json:"publish_nebim"`
IsActive bool `json:"is_active"`
TryBase float64 `json:"try_base"` TryBase float64 `json:"try_base"`
Try1 float64 `json:"try1"` Try1 float64 `json:"try1"`
@@ -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

View File

@@ -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;
` `

View 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,'')));
`

View 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
}

View 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
}

View 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})
}
}

View File

@@ -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")),

View 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)
}
}

View 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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&#39;",
)
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))
}
}
}

View 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,
})
}
}

View 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())
}
}

View 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,
})
}
}

View File

@@ -1,75 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { Quasar } from 'quasar'
import { markRaw } from 'vue'
import RootComponent from 'app/src/App.vue'
import createStore from 'app/src/stores/index'
import createRouter from 'app/src/router/index'
export default async function (createAppFn, quasarUserOptions) {
// Create the app instance.
// Here we inject into it the Quasar UI, the router & possibly the store.
const app = createAppFn(RootComponent)
app.use(Quasar, quasarUserOptions)
const store = typeof createStore === 'function'
? await createStore({})
: createStore
app.use(store)
const router = markRaw(
typeof createRouter === 'function'
? await createRouter({store})
: createRouter
)
// make router instance available in store
store.use(({ store }) => { store.router = router })
// Expose the app, the router and the store.
// Note that we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {
app,
store,
router
}
}

View File

@@ -1,158 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { createApp } from 'vue'
import '@quasar/extras/roboto-font/roboto-font.css'
import '@quasar/extras/material-icons/material-icons.css'
// We load Quasar stylesheet file
import 'quasar/dist/quasar.sass'
import 'src/css/app.css'
import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
const publicPath = `/`
async function start ({
app,
router
, store
}, bootFiles) {
let hasRedirected = false
const getRedirectUrl = url => {
try { return router.resolve(url).href }
catch (err) {}
return Object(url) === url
? null
: url
}
const redirect = url => {
hasRedirected = true
if (typeof url === 'string' && /^https?:\/\//.test(url)) {
window.location.href = url
return
}
const href = getRedirectUrl(url)
// continue if we didn't fail to resolve the url
if (href !== null) {
window.location.href = href
window.location.reload()
}
}
const urlPath = window.location.href.replace(window.location.origin, '')
for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) {
try {
await bootFiles[i]({
app,
router,
store,
ssrContext: null,
redirect,
urlPath,
publicPath
})
}
catch (err) {
if (err && err.url) {
redirect(err.url)
return
}
console.error('[Quasar] boot error:', err)
return
}
}
if (hasRedirected === true) return
app.use(router)
app.mount('#q-app')
}
createQuasarApp(createApp, quasarUserOptions)
.then(app => {
// eventually remove this when Cordova/Capacitor/Electron support becomes old
const [ method, mapFn ] = Promise.allSettled !== void 0
? [
'allSettled',
bootFiles => bootFiles.map(result => {
if (result.status === 'rejected') {
console.error('[Quasar] boot error:', result.reason)
return
}
return result.value.default
})
]
: [
'all',
bootFiles => bootFiles.map(entry => entry.default)
]
return Promise[ method ]([
import(/* webpackMode: "eager" */ 'boot/dayjs'),
import(/* webpackMode: "eager" */ 'boot/locale'),
import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
start(app, boot)
})
})

View File

@@ -1,116 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import App from 'app/src/App.vue'
let appPrefetch = typeof App.preFetch === 'function'
? App.preFetch
: (
// Class components return the component options (and the preFetch hook) inside __c property
App.__c !== void 0 && typeof App.__c.preFetch === 'function'
? App.__c.preFetch
: false
)
function getMatchedComponents (to, router) {
const route = to
? (to.matched ? to : router.resolve(to).route)
: router.currentRoute.value
if (!route) { return [] }
const matched = route.matched.filter(m => m.components !== void 0)
if (matched.length === 0) { return [] }
return Array.prototype.concat.apply([], matched.map(m => {
return Object.keys(m.components).map(key => {
const comp = m.components[key]
return {
path: m.path,
c: comp
}
})
}))
}
export function addPreFetchHooks ({ router, store, publicPath }) {
// Add router hook for handling preFetch.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const
urlPath = window.location.href.replace(window.location.origin, ''),
matched = getMatchedComponents(to, router),
prevMatched = getMatchedComponents(from, router)
let diffed = false
const preFetchList = matched
.filter((m, i) => {
return diffed || (diffed = (
!prevMatched[i] ||
prevMatched[i].c !== m.c ||
m.path.indexOf('/:') > -1 // does it has params?
))
})
.filter(m => m.c !== void 0 && (
typeof m.c.preFetch === 'function'
// Class components return the component options (and the preFetch hook) inside __c property
|| (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function')
))
.map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch)
if (appPrefetch !== false) {
preFetchList.unshift(appPrefetch)
appPrefetch = false
}
if (preFetchList.length === 0) {
return next()
}
let hasRedirected = false
const redirect = url => {
hasRedirected = true
next(url)
}
const proceed = () => {
if (hasRedirected === false) { next() }
}
preFetchList.reduce(
(promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({
store,
currentRoute: to,
previousRoute: from,
redirect,
urlPath,
publicPath
})),
Promise.resolve()
)
.then(proceed)
.catch(e => {
console.error(e)
proceed()
})
})
}

View File

@@ -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} }

View 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>

View File

@@ -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', {
const item = { at: new Date(startedAt).toISOString(),
id: row.id, dirty_count: dirty.length
pricing_parameter_id: row.pricing_parameter_id,
is_active: Boolean(row.is_active)
}
for (const key of numericFields) item[key] = finiteNumber(row[key], 0)
return item
})
}
await api.request({
method: 'POST',
url: '/pricing/pricing-rules/bulk-save',
data: payload,
timeout: 180000
}) })
const buildPayload = (list) => {
return {
items: list.map(row => {
const item = {
id: row.id,
pricing_parameter_id: row.pricing_parameter_id,
calc_enabled: Boolean(row.calc_enabled),
publish_postgres: Boolean(row.publish_postgres),
publish_nebim: Boolean(row.publish_nebim),
is_active: Boolean(row.is_active),
try_retail_mode: String(row.try_retail_mode || 'STEP'),
usd_retail_mode: String(row.usd_retail_mode || 'STEP'),
eur_retail_mode: String(row.eur_retail_mode || 'STEP')
}
for (const key of numericFields) item[key] = finiteNumber(row[key], 0)
return item
})
}
}
const makeTraceId = () => `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
const isTimeoutLikeError = (e) => {
const status = e?.response?.status || null
// With axios timeout disabled for bulk-save, treat only real upstream/proxy timeouts as retry-able.
return status === 504
}
let savedTotal = 0
const failedKeys = []
const postBulkSave = async (list) => {
const traceId = makeTraceId()
const payload = buildPayload(list)
await api.request({
method: 'POST',
url: '/pricing/pricing-rules/bulk-save',
data: payload,
// 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;
} }

View File

@@ -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,79 +51,134 @@
/> />
</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-list dense class="currency-menu-list"> <q-btn
<q-item clickable @click="selectAllCurrencies"> dense
<q-item-section>Tumunu Sec</q-item-section> flat
</q-item> color="grey-8"
<q-item clickable @click="clearAllCurrencies"> icon="view_sidebar"
<q-item-section>Tumunu Temizle</q-item-section> :label="leftDetailsExpanded ? 'Detaylari Gizle' : 'Detaylari Goster'"
</q-item> @click="leftDetailsExpanded = !leftDetailsExpanded"
<q-separator /> />
<q-item v-for="option in currencyOptions" :key="option.value" clickable @click="toggleCurrencyRow(option.value)"> <q-btn-dropdown dense color="secondary" outline icon="view_module" label="Doviz Gorunumu" :auto-close="false">
<q-item-section avatar> <q-list dense class="currency-menu-list">
<q-checkbox <q-item clickable @click="selectAllCurrencies">
:model-value="isCurrencySelected(option.value)" <q-item-section>Tumunu Sec</q-item-section>
dense </q-item>
@update:model-value="(val) => toggleCurrency(option.value, val)" <q-item clickable @click="clearAllCurrencies">
@click.stop <q-item-section>Tumunu Temizle</q-item-section>
/> </q-item>
</q-item-section> <q-separator />
<q-item-section>{{ option.label }}</q-item-section> <q-item v-for="option in currencyOptions" :key="option.value" clickable @click="toggleCurrencyRow(option.value)">
</q-item> <q-item-section avatar>
</q-list> <q-checkbox
</q-btn-dropdown> :model-value="isCurrencySelected(option.value)"
<q-btn dense
flat @update:model-value="(val) => toggleCurrency(option.value, val)"
:color="showSelectedOnly ? 'primary' : 'grey-7'" @click.stop
:icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'" />
:label="showSelectedOnly ? `Secililer (${selectedRowCount})` : 'Secili Olanlari Getir'" </q-item-section>
:disable="!showSelectedOnly && selectedRowCount === 0" <q-item-section>{{ option.label }}</q-item-section>
@click="toggleShowSelectedOnly" </q-item>
/> </q-list>
<q-btn </q-btn-dropdown>
color="primary" </div>
outline
icon="edit_note" <div class="toolbar-group">
label="Secili Olanlari Toplu Degistir" <q-btn
:disable="selectedRowCount === 0" dense
@click="bulkDialogOpen = true" flat
/> :color="showSelectedOnly ? 'primary' : 'grey-7'"
<q-btn :icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'"
color="primary" :label="showSelectedOnly ? `Secililer (${selectedRowCount})` : 'Secili Olanlari Getir'"
flat :disable="!showSelectedOnly && selectedRowCount === 0"
icon="download" @click="toggleShowSelectedOnly"
label="Sayfayi Excel'e Aktar" />
:disable="filteredRows.length === 0" <q-btn
@click="exportCurrentView" dense
/> color="primary"
<q-btn outline
color="primary" icon="calculate"
outline label="Secilileri Hesapla"
icon="download_for_offline" :disable="selectedRowCount === 0 || bulkCalcLoading"
label="Tum Filtreyi Excel'e Aktar" :loading="bulkCalcLoading"
:disable="filteredRows.length === 0 || exportAllLoading" @click="calculateSelectedRows"
:loading="exportAllLoading" />
@click="exportAllFiltered" <q-btn
/> dense
<q-pagination color="primary"
v-model="currentPage" icon="save"
color="primary" :label="saveButtonLabel"
:max="Math.max(1, store.totalPages || 1)" :disable="selectedDirtyCount === 0 || saving"
:max-pages="8" :loading="saving"
boundary-links @click="saveSelectedRows"
direction-links />
@update:model-value="onPageChange" </div>
/>
<div class="text-caption text-grey-8"> <div class="toolbar-group">
Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu <q-btn-dropdown dense color="primary" outline icon="download" label="Cikti Al" :auto-close="true">
<q-list dense style="min-width: 260px;">
<q-item clickable :disable="filteredRows.length === 0" @click="exportCurrentView">
<q-item-section avatar><q-icon name="grid_on" /></q-item-section>
<q-item-section>Sayfayi Excel'e Aktar</q-item-section>
</q-item>
<q-item clickable :disable="filteredRows.length === 0 || exportAllLoading" @click="exportAllFiltered">
<q-item-section avatar><q-icon name="download_for_offline" /></q-item-section>
<q-item-section>Tum Filtreyi Excel'e Aktar</q-item-section>
</q-item>
<q-separator />
<q-item clickable :disable="store.loading" @click="openPriceListExportDialog()">
<q-item-section avatar><q-icon name="receipt_long" /></q-item-section>
<q-item-section>Fiyat Listesi Ciktisi...</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<q-space />
<div class="toolbar-group toolbar-group--paging">
<q-pagination
v-model="currentPage"
color="primary"
:max="Math.max(1, store.totalPages || 1)"
:max-pages="8"
boundary-links
direction-links
@update:model-value="onPageChange"
/>
<div class="text-caption text-grey-8">
Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu
</div>
</div> </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)"
> >
<input <div v-if="editableColumnSet.has(props.col.name)" class="editable-price-cell">
v-if="editableColumnSet.has(props.col.name)" <input
class="native-cell-input text-right" class="native-cell-input text-right price-edit-input"
: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"
<div class="text-caption text-grey-8"> :disable="!priceHistoryRow?.productCode"
Uygulanacak satir sayisi: {{ selectedRowCount }} @click="reloadPriceHistory()"
/>
</div>
</q-card-section>
<q-card-section class="q-pt-sm">
<q-inner-loading :showing="priceHistoryLoading">
<q-spinner-gears size="46px" color="primary" />
</q-inner-loading>
<q-tabs v-model="priceHistoryTab" dense inline-label class="text-grey-8" active-color="primary">
<q-tab name="pg" icon="storefront" label="B2B/B2C" />
<q-tab name="mssql" icon="dns" label="NEBIM_V3" />
</q-tabs>
<q-separator class="q-mt-xs q-mb-sm" />
<q-tab-panels v-model="priceHistoryTab" animated>
<q-tab-panel name="pg" class="q-pa-none">
<div v-if="pgHistoryGroups.length === 0" class="text-caption text-grey-7 q-pa-sm">
Kayit bulunamadi.
</div>
<q-list v-else dense bordered separator>
<q-expansion-item
v-for="g in pgHistoryGroups"
:key="g.key"
expand-separator
:label="`${g.currency} ${(g.levelNo <= 5) ? 'B2B' : 'B2C'} Level ${g.levelNo}`"
:caption="`${g.rows.length} kayit | Son: ${formatMoney(g.latest?.price)} @ ${g.latest?.updated_at || '-'}`"
>
<q-item v-for="r in g.rows" :key="r.id">
<q-item-section avatar>
<q-checkbox
:model-value="selectedPgIdSet.has(r.id)"
dense
@update:model-value="(val) => toggleSelectedPgId(r.id, val)"
@click.stop
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ formatMoney(r.price) }}</q-item-label>
<q-item-label caption>{{ r.updated_at }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge color="grey-6" outline>{{ r.currency }} {{ r.level_no }}</q-badge>
</q-item-section>
</q-item>
</q-expansion-item>
</q-list>
</q-tab-panel>
<q-tab-panel name="mssql" class="q-pa-none">
<div v-if="mssqlHistoryGroups.length === 0" class="text-caption text-grey-7 q-pa-sm">
Kayit bulunamadi.
</div>
<q-list v-else dense bordered separator>
<q-expansion-item
v-for="g in mssqlHistoryGroups"
:key="g.key"
expand-separator
:label="`${g.currency} ${g.price_group_code}`"
:caption="`${g.rows.length} kayit | Son: ${formatMoney(g.latest?.price)} @ ${formatMssqlStamp(g.latest)}`"
>
<q-item v-for="r in g.rows" :key="r.price_list_line_id">
<q-item-section avatar>
<q-checkbox
:model-value="selectedMssqlIdSet.has(r.price_list_line_id)"
dense
@update:model-value="(val) => toggleSelectedMssqlId(r.price_list_line_id, val)"
@click.stop
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ formatMoney(r.price) }}</q-item-label>
<q-item-label caption>{{ formatMssqlStamp(r) }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge color="grey-6" outline>{{ r.currency }} {{ r.price_group_code }}</q-badge>
</q-item-section>
</q-item>
</q-expansion-item>
</q-list>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="priceListExportDialogOpen" persistent>
<q-card style="min-width: 740px; max-width: 95vw;">
<q-card-section class="row items-center justify-between">
<div class="text-subtitle1 text-weight-bold">
Fiyat Listesi Ciktisi
</div>
<q-btn flat round icon="close" color="grey-8" @click="priceListExportDialogOpen = false" />
</q-card-section>
<q-separator />
<q-card-section class="q-gutter-sm">
<div class="row items-center q-gutter-sm">
<q-btn-toggle
v-model="priceListExportFormat"
dense
unelevated
toggle-color="primary"
color="grey-3"
text-color="grey-9"
:options="[
{ label: 'PDF', value: 'pdf', icon: 'picture_as_pdf' },
{ label: 'Excel', value: 'excel', icon: 'grid_on' }
]"
/>
<q-toggle v-model="priceListInStockOnly" label="Sadece stogu olan urunler" />
<q-space />
<q-btn
color="primary"
icon="download"
:label="priceListExportFormat === 'pdf' ? 'PDF Olustur' : 'Excel Olustur'"
:loading="priceListExportLoading"
@click="runPriceListExport"
/>
</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-select
v-model="priceListUrunIlkGrubu"
dense
outlined
clearable
emit-value
map-options
:options="topUrunIlkGrubuOptions"
:loading="Boolean(serverFilterLoading.urunIlkGrubu)"
label="Urun Ilk Grubu"
@filter="onTopFilterSearchUrunIlkGrubu"
@update:model-value="onPriceListUrunIlkGrubuChange"
/>
</div>
<div class="col-12 col-md-4">
<q-select
v-model="priceListUrunAnaGrubu"
dense
outlined
clearable
multiple
use-chips
emit-value
map-options
:options="topUrunAnaGrubuOptions"
:loading="Boolean(serverFilterLoading.urunAnaGrubu)"
label="Urun Ana Grubu (max 3)"
@filter="onTopFilterSearchUrunAnaGrubu"
@update:model-value="onPriceListUrunAnaGrubuChange"
/>
</div>
<div class="col-12 col-md-4">
<q-select
v-model="priceListUrunAltGrubu"
dense
outlined
clearable
multiple
use-chips
emit-value
map-options
:options="priceListUrunAltGrubuOptions"
:loading="Boolean(serverFilterLoading.urunAltGrubu)"
label="Urun Alt Grubu"
@filter="onPriceListFilterSearchUrunAltGrubu"
/>
</div>
</div>
<div class="row items-center q-gutter-sm">
<q-toggle v-model="priceListIncludeCost" label="Maliyet fiyati" />
<q-toggle v-model="priceListIncludeBase" label="Taban fiyatlar (USD/TRY)" />
</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<div class="text-caption text-grey-8 q-mb-xs">USD seviyeleri</div>
<q-option-group
v-model="priceListUSDLevels"
type="checkbox"
dense
:options="priceLevelOptionsUSD"
/>
</div>
<div class="col-12 col-md-4">
<div class="text-caption text-grey-8 q-mb-xs">EUR seviyeleri</div>
<q-option-group
v-model="priceListEURLevels"
type="checkbox"
dense
:options="priceLevelOptionsEUR"
/>
</div>
<div class="col-12 col-md-4">
<div class="text-caption text-grey-8 q-mb-xs">TRY seviyeleri</div>
<q-option-group
v-model="priceListTRYLevels"
type="checkbox"
dense
:options="priceLevelOptionsTRY"
/>
</div>
</div> </div>
</q-card-section> </q-card-section>
<q-card-actions align="right">
<q-btn flat label="Iptal" v-close-popup />
<q-btn color="primary" label="Uygula" @click="applyBulkUpdate" />
</q-card-actions>
</q-card> </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,
toggleRowSelection(rowSelectionKey(row), true) [productCode]: !!value
}
}
function applyPreviewRowToUiRow (row, preview) {
row.basePriceUsd = round2(preview?.base_price_usd)
row.basePriceTry = round2(preview?.base_price_try)
row.usd1 = round2(preview?.usd1)
row.usd2 = round2(preview?.usd2)
row.usd3 = round2(preview?.usd3)
row.usd4 = round2(preview?.usd4)
row.usd5 = round2(preview?.usd5)
row.usd6 = round2(preview?.usd6)
row.eur1 = round2(preview?.eur1)
row.eur2 = round2(preview?.eur2)
row.eur3 = round2(preview?.eur3)
row.eur4 = round2(preview?.eur4)
row.eur5 = round2(preview?.eur5)
row.eur6 = round2(preview?.eur6)
row.try1 = round2(preview?.try1)
row.try2 = round2(preview?.try2)
row.try3 = round2(preview?.try3)
row.try4 = round2(preview?.try4)
row.try5 = round2(preview?.try5)
row.try6 = round2(preview?.try6)
}
async function calculateRow (row) {
if (!row?.productCode) return
const productCode = String(row.productCode).trim()
if (!productCode) return
setCalcLoading(productCode, true)
console.info('[product-pricing][ui] calc-row:start', { product_code: productCode })
try {
const res = await api.post('/pricing/products/calculate-snapshots', {
preview_only: true,
product_codes: [productCode]
}, {
timeout: 180000
})
const list = Array.isArray(res?.data?.rows) ? res.data.rows : []
const preview = list.find((item) => String(item?.product_code || '').trim() === productCode)
if (!preview) {
Notify.create({ type: 'warning', message: 'Bu urun icin hesap sonucu donmedi.' })
return
}
applyPreviewRowToUiRow(row, preview)
toggleRowSelection(rowSelectionKey(row), true)
console.info('[product-pricing][ui] calc-row:done', { product_code: productCode })
} catch (err) {
console.error('[product-pricing][ui] calc-row:error', {
product_code: productCode,
status: err?.response?.status ?? null,
message: err?.response?.data || err?.message || 'calc-row failed'
})
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Hesaplama onizlemesi alinamadi' })
} finally {
setCalcLoading(productCode, false)
}
}
async function openPriceHistoryDialog (row) {
if (!row?.productCode) return
priceHistoryRow.value = row
priceHistoryTab.value = 'pg'
priceHistory.value = { postgres: [], mssql: [] }
selectedPgIds.value = []
selectedMssqlIds.value = []
priceHistoryDialogOpen.value = true
await reloadPriceHistory()
}
async function reloadPriceHistory () {
const code = String(priceHistoryRow.value?.productCode || '').trim()
if (!code) return
priceHistoryLoading.value = true
try {
const traceId = `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
const res = await api.request({
method: 'GET',
url: `/pricing/products/${encodeURIComponent(code)}/price-history`,
timeout: 180000,
headers: { 'X-Trace-ID': traceId }
})
priceHistory.value = {
postgres: Array.isArray(res?.data?.postgres) ? res.data.postgres : [],
mssql: Array.isArray(res?.data?.mssql) ? res.data.mssql : []
}
// keep selection but drop ids that no longer exist
const pgSet = new Set(priceHistory.value.postgres.map((r) => String(r?.id || '').trim()).filter(Boolean))
const msSet = new Set(priceHistory.value.mssql.map((r) => String(r?.price_list_line_id || '').trim()).filter(Boolean))
selectedPgIds.value = (selectedPgIds.value || []).filter((id) => pgSet.has(id))
selectedMssqlIds.value = (selectedMssqlIds.value || []).filter((id) => msSet.has(id))
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Fiyat gecmisi yuklenemedi' })
} finally {
priceHistoryLoading.value = false
}
}
function toggleSelectedPgId (id, val) {
const sid = String(id || '').trim()
if (!sid) return
const set = new Set(selectedPgIds.value || [])
if (val) set.add(sid)
else set.delete(sid)
selectedPgIds.value = Array.from(set)
}
function toggleSelectedMssqlId (id, val) {
const sid = String(id || '').trim()
if (!sid) return
const set = new Set(selectedMssqlIds.value || [])
if (val) set.add(sid)
else set.delete(sid)
selectedMssqlIds.value = Array.from(set)
}
async function confirmDeleteSelectedHistory () {
const code = String(priceHistoryRow.value?.productCode || '').trim()
if (!code) return
const pgCount = selectedPgIds.value?.length || 0
const msCount = selectedMssqlIds.value?.length || 0
if (pgCount + msCount === 0) return
await $q.dialog({
title: 'Secilenleri Sil',
message: `Secili kayitlari silmek istiyor musunuz? (B2B/B2C: ${pgCount}, NEBIM_V3: ${msCount})`,
cancel: true,
persistent: true,
ok: { label: 'Sil', color: 'negative' },
cancel: { label: 'Vazgec', color: 'grey-7', flat: true }
}).onOk(async () => {
const traceId = `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
const payload = {
pg_ids: (selectedPgIds.value || []).map((x) => String(x || '').trim()).filter(Boolean),
mssql_ids: (selectedMssqlIds.value || []).map((x) => String(x || '').trim()).filter(Boolean)
}
await api.request({
method: 'POST',
url: `/pricing/products/${encodeURIComponent(code)}/price-history/delete-selected`,
data: payload,
timeout: 180000,
headers: { 'X-Trace-ID': traceId }
})
Notify.create({ type: 'positive', message: 'Secilen kayitlar silindi.' })
selectedPgIds.value = []
selectedMssqlIds.value = []
await reloadPriceHistory()
await reloadData({ page: currentPage.value })
})
}
let tableMiddleScrollEl = null
let horizontalResizeObserver = null
let syncingTopScroll = false
function getTableMiddleScrollEl () {
return mainTableRef.value?.$el?.querySelector('.q-table__middle') || null
}
function syncTopScrollWidth () {
const top = topScrollRef.value
const inner = topScrollInnerRef.value
const middle = getTableMiddleScrollEl()
if (!top || !inner || !middle) return
const scrollWidth = Math.max(middle.scrollWidth, tableMinWidth.value, top.clientWidth)
inner.style.width = `${scrollWidth}px`
if (top.scrollLeft !== middle.scrollLeft) {
top.scrollLeft = middle.scrollLeft
}
}
function onTopScroll () {
const top = topScrollRef.value
const middle = getTableMiddleScrollEl()
if (!top || !middle || syncingTopScroll) return
syncingTopScroll = true
middle.scrollLeft = top.scrollLeft
requestAnimationFrame(() => {
syncingTopScroll = false
})
}
function onTableMiddleScroll () {
const top = topScrollRef.value
const middle = getTableMiddleScrollEl()
if (!top || !middle || syncingTopScroll) return
syncingTopScroll = true
top.scrollLeft = middle.scrollLeft
requestAnimationFrame(() => {
syncingTopScroll = false
})
}
async function bindHorizontalScrollSync () {
await nextTick()
const middle = getTableMiddleScrollEl()
if (tableMiddleScrollEl && tableMiddleScrollEl !== middle) {
tableMiddleScrollEl.removeEventListener('scroll', onTableMiddleScroll)
}
tableMiddleScrollEl = middle
if (tableMiddleScrollEl) {
tableMiddleScrollEl.removeEventListener('scroll', onTableMiddleScroll)
tableMiddleScrollEl.addEventListener('scroll', onTableMiddleScroll, { passive: true })
}
if (horizontalResizeObserver) {
horizontalResizeObserver.disconnect()
horizontalResizeObserver = null
}
if (typeof ResizeObserver !== 'undefined') {
horizontalResizeObserver = new ResizeObserver(() => {
syncTopScrollWidth()
})
if (topScrollRef.value) horizontalResizeObserver.observe(topScrollRef.value)
if (tableMiddleScrollEl) horizontalResizeObserver.observe(tableMiddleScrollEl)
}
syncTopScrollWidth()
} }
function onBrandGroupSelectionChange (row, val) { 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,

View File

@@ -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',

View File

@@ -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
}) })
} }

View File

@@ -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 = []) {