diff --git a/logs/backend-20260617-171953.err.log b/logs/backend-20260617-171953.err.log new file mode 100644 index 0000000..c260a1f --- /dev/null +++ b/logs/backend-20260617-171953.err.log @@ -0,0 +1 @@ +exit status 1 diff --git a/logs/backend-20260617-171953.out.log b/logs/backend-20260617-171953.out.log new file mode 100644 index 0000000..12479b0 --- /dev/null +++ b/logs/backend-20260617-171953.out.log @@ -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 diff --git a/logs/ui-dev-20260617-171953.err.log b/logs/ui-dev-20260617-171953.err.log new file mode 100644 index 0000000..2cf3ab4 --- /dev/null +++ b/logs/ui-dev-20260617-171953.err.log @@ -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? + diff --git a/logs/ui-dev-20260617-171953.out.log b/logs/ui-dev-20260617-171953.out.log new file mode 100644 index 0000000..40179a2 --- /dev/null +++ b/logs/ui-dev-20260617-171953.out.log @@ -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 diff --git a/svc/main.go b/svc/main.go index fda030b..16e3a70 100644 --- a/svc/main.go +++ b/svc/main.go @@ -805,6 +805,41 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "pricing", "view", wrapV3(http.HandlerFunc(routes.ExportAllProductPricingHandler)), ) + bindV3(r, pgDB, + "/api/pricing/products/price-list/export-excel", "POST", + "pricing", "view", + wrapV3(http.HandlerFunc(routes.ExportProductPriceListExcelHandler(pgDB))), + ) + bindV3(r, pgDB, + "/api/pricing/products/price-list/export-pdf", "POST", + "pricing", "view", + wrapV3(http.HandlerFunc(routes.ExportProductPriceListPDFHandler(pgDB))), + ) + bindV3(r, pgDB, + "/api/pricing/products/calculate-snapshots", "POST", + "pricing", "update", + wrapV3(routes.PostProductPricingCalculateSnapshotsHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/products/{code}/price-history", "GET", + "pricing", "view", + wrapV3(routes.GetProductPricingHistoryHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/products/{code}/price-history/delete-latest", "POST", + "pricing", "update", + wrapV3(routes.PostDeleteLatestProductPriceHistoryHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/products/{code}/price-history/delete-selected", "POST", + "pricing", "update", + wrapV3(routes.PostDeleteSelectedProductPriceHistoryHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/products/save", "POST", + "pricing", "update", + wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)), + ) bindV3(r, pgDB, "/api/pricing/brand-classification/lookups", "GET", "pricing", "view", @@ -830,6 +865,16 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "pricing", "update", wrapV3(routes.SetBrandGroupsBulkHandler(pgDB)), ) + bindV3(r, pgDB, + "/api/pricing/brand-group-currency", "GET", + "pricing", "view", + wrapV3(routes.GetBrandGroupCurrencyHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/brand-group-currency/bulk-save", "POST", + "pricing", "update", + wrapV3(routes.SaveBrandGroupCurrencyHandler(pgDB)), + ) bindV3(r, pgDB, "/api/pricing/pricing-rules", "GET", "pricing", "view", @@ -1162,6 +1207,9 @@ func main() { if err := queries.EnsurePricingParameterTables(pgDB); err != nil { log.Println("mk_urunpricingprmtr bootstrap failed:", err) } + if err := queries.EnsurePricingCalcInfraTables(pgDB); err != nil { + log.Println("pricing calc infra bootstrap failed:", err) + } // ------------------------------------------------------- // βοΈ MAILER INIT @@ -1184,6 +1232,8 @@ func main() { startTranslationSyncScheduler(pgDB, db.MssqlDB) startBrandSyncScheduler(pgDB, db.MssqlDB) startPricingParameterSyncScheduler(pgDB, db.MssqlDB) + startProductPricingFxDeltaScheduler(pgDB) + startProductPricingFxFullScheduler(pgDB) handler := enableCORS( middlewares.GlobalAuthMiddleware( diff --git a/svc/product_pricing_fx_delta_scheduler.go b/svc/product_pricing_fx_delta_scheduler.go new file mode 100644 index 0000000..7ccb914 --- /dev/null +++ b/svc/product_pricing_fx_delta_scheduler.go @@ -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") + } + }() +} diff --git a/svc/product_pricing_fx_full_scheduler.go b/svc/product_pricing_fx_full_scheduler.go new file mode 100644 index 0000000..e349fe2 --- /dev/null +++ b/svc/product_pricing_fx_full_scheduler.go @@ -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") + } + }() +} diff --git a/svc/queries/brand_classification.go b/svc/queries/brand_classification.go index c8a13be..25c7546 100644 --- a/svc/queries/brand_classification.go +++ b/svc/queries/brand_classification.go @@ -22,6 +22,7 @@ type BrandGroupOption struct { Code string `json:"code"` Title string `json:"title"` Description string `json:"description"` + AnchorMode string `json:"anchor_mode"` } func EnsureBrandClassificationTables(pg *sql.DB) error { @@ -41,10 +42,15 @@ CREATE TABLE IF NOT EXISTS mk_brandgrp ( code TEXT NOT NULL UNIQUE, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', + anchor_mode TEXT NOT NULL DEFAULT 'USD', sort_order SMALLINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now() )`, `ALTER TABLE mk_brandgrp ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE mk_brandgrp ADD COLUMN IF NOT EXISTS anchor_mode TEXT NOT NULL DEFAULT 'USD'`, + `UPDATE mk_brandgrp SET anchor_mode='USD' WHERE COALESCE(NULLIF(BTRIM(anchor_mode), ''), '') = ''`, + `ALTER TABLE mk_brandgrp DROP CONSTRAINT IF EXISTS ck_mk_brandgrp_anchor_mode`, + `ALTER TABLE mk_brandgrp ADD CONSTRAINT ck_mk_brandgrp_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))`, ` INSERT INTO mk_brandgrp (id, code, title, description, sort_order) VALUES @@ -74,7 +80,7 @@ CREATE TABLE IF NOT EXISTS mk_brandgrpmatch ( } func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error) { - rows, err := pg.QueryContext(ctx, `SELECT id, code, title, description FROM mk_brandgrp ORDER BY sort_order, id`) + rows, err := pg.QueryContext(ctx, `SELECT id, code, title, description, anchor_mode FROM mk_brandgrp ORDER BY sort_order, id`) if err != nil { return nil, err } @@ -82,17 +88,57 @@ func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error out := make([]BrandGroupOption, 0, 8) for rows.Next() { var o BrandGroupOption - if err := rows.Scan(&o.ID, &o.Code, &o.Title, &o.Description); err != nil { + if err := rows.Scan(&o.ID, &o.Code, &o.Title, &o.Description, &o.AnchorMode); err != nil { return nil, err } o.Code = strings.TrimSpace(o.Code) o.Title = strings.TrimSpace(o.Title) o.Description = strings.TrimSpace(o.Description) + o.AnchorMode = strings.ToUpper(strings.TrimSpace(o.AnchorMode)) + if o.AnchorMode == "" { + o.AnchorMode = "USD" + } out = append(out, o) } return out, rows.Err() } +func SetBrandGroupAnchorMode(ctx context.Context, tx *sql.Tx, grpID int, anchorMode string) error { + anchorMode = strings.ToUpper(strings.TrimSpace(anchorMode)) + if anchorMode == "" { + anchorMode = "USD" + } + _, err := tx.ExecContext(ctx, ` +UPDATE mk_brandgrp +SET anchor_mode=$2 +WHERE id=$1 +`, grpID, anchorMode) + return err +} + +func SyncPricingRuleAnchorModesByGroup(ctx context.Context, tx *sql.Tx, grpID int, anchorMode string) error { + anchorMode = strings.ToUpper(strings.TrimSpace(anchorMode)) + if anchorMode == "" { + anchorMode = "USD" + } + _, err := tx.ExecContext(ctx, ` +UPDATE mk_pricing_rule r +SET anchor_mode=$2, + updated_at=now() +WHERE EXISTS ( + SELECT 1 + FROM mk_brandgrp g + JOIN LATERAL unnest(r.brand_group) bg(value) ON TRUE + WHERE g.id=$1 + AND ( + UPPER(BTRIM(bg.value)) = UPPER(BTRIM(g.code)) + OR UPPER(BTRIM(bg.value)) = UPPER(BTRIM(g.title)) + ) +) +`, grpID, anchorMode) + return err +} + func ListBrandsWithGroups(ctx context.Context, pg *sql.DB, q string, limit int) ([]BrandRow, error) { if limit <= 0 { limit = 5000 diff --git a/svc/queries/pricing_calc_engine.go b/svc/queries/pricing_calc_engine.go new file mode 100644 index 0000000..9b67756 --- /dev/null +++ b/svc/queries/pricing_calc_engine.go @@ -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[:]) +} diff --git a/svc/queries/pricing_calc_infra.go b/svc/queries/pricing_calc_infra.go new file mode 100644 index 0000000..7e3f280 --- /dev/null +++ b/svc/queries/pricing_calc_infra.go @@ -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 +} diff --git a/svc/queries/pricing_parameters.go b/svc/queries/pricing_parameters.go index 1057b17..8dbb847 100644 --- a/svc/queries/pricing_parameters.go +++ b/svc/queries/pricing_parameters.go @@ -196,7 +196,6 @@ SELECT icerik, marka, brand_code, brand_group_sec, scope_key FROM mk_urunpricingprmtr WHERE id=$1 - AND is_active=TRUE `, pricingParameterID).Scan( &p.AskiliYan, &p.Kategori, @@ -441,6 +440,12 @@ WHERE ProductAtt42 IN ('SERI', 'AKSESUAR') } defer tx.Rollback() + // Serialize writes touching mk_urunpricingprmtr/mk_pricing_rule/mk_pricex/mk_priceroll + // to avoid deadlocks with bulk-save/import flows. + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil { + return out, err + } + if _, err := tx.ExecContext(ctx, ` CREATE TEMP TABLE tmp_urunpricingprmtr_sync ( askili_yan TEXT NOT NULL, @@ -714,6 +719,17 @@ SELECT p.brand_code, p.brand_group_sec, COALESCE(r.id::text, ''), + COALESCE( + r.strategy_code, + CASE + WHEN UPPER(BTRIM(p.brand_group_sec)) IN ('CORE','PREMIUM','SARTORIAL') THEN UPPER(BTRIM(p.brand_group_sec)) + ELSE 'CORE' + END + ), + COALESCE(r.anchor_mode, bg.anchor_mode, 'USD'), + COALESCE(r.calc_enabled, TRUE), + COALESCE(r.publish_postgres, TRUE), + COALESCE(r.publish_nebim, TRUE), COALESCE(r.is_active, TRUE), COALESCE(tx.base_mult, 0)::float8, @@ -725,6 +741,7 @@ SELECT COALESCE(tx.m6, 0)::float8, COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8, COALESCE(NULLIF(tr.retail_step, 0), tr.step, 0)::float8, + COALESCE(NULLIF(BTRIM(tr.retail_mode), ''), 'STEP'), COALESCE(ux.base_mult, 0)::float8, COALESCE(ux.m1, 0)::float8, @@ -735,6 +752,7 @@ SELECT COALESCE(ux.m6, 0)::float8, COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8, COALESCE(NULLIF(ur.retail_step, 0), ur.step, 0)::float8, + COALESCE(NULLIF(BTRIM(ur.retail_mode), ''), 'STEP'), COALESCE(ex.base_mult, 0)::float8, COALESCE(ex.m1, 0)::float8, @@ -744,7 +762,8 @@ SELECT COALESCE(ex.m5, 0)::float8, COALESCE(ex.m6, 0)::float8, COALESCE(NULLIF(er.wholesale_step, 0), er.step, 0)::float8, - COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8 + COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8, + COALESCE(NULLIF(BTRIM(er.retail_mode), ''), 'STEP') FROM mk_urunpricingprmtr p LEFT JOIN LATERAL ( SELECT latest_rule.* @@ -753,6 +772,14 @@ LEFT JOIN LATERAL ( ORDER BY latest_rule.created_at DESC, latest_rule.updated_at DESC, latest_rule.id DESC LIMIT 1 ) r ON TRUE +LEFT JOIN LATERAL ( + SELECT g.anchor_mode + FROM mk_brandgrp g + WHERE UPPER(BTRIM(g.code)) = UPPER(BTRIM(p.brand_group_sec)) + OR UPPER(BTRIM(g.title)) = UPPER(BTRIM(p.brand_group_sec)) + ORDER BY g.id + LIMIT 1 +) bg ON TRUE LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY' LEFT JOIN mk_pricex ux ON ux.rule_id = r.id AND ux.currency='USD' LEFT JOIN mk_pricex ex ON ex.rule_id = r.id AND ex.currency='EUR' @@ -790,13 +817,21 @@ ORDER BY &item.BrandCode, &item.BrandGroupSec, &rule.ID, + &rule.StrategyCode, + &rule.AnchorMode, + &rule.CalcEnabled, + &rule.PublishPostgres, + &rule.PublishNebim, &rule.IsActive, - &rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryWholesaleStep, &rule.TryRetailStep, - &rule.UsdBase, &rule.Usd1, &rule.Usd2, &rule.Usd3, &rule.Usd4, &rule.Usd5, &rule.Usd6, &rule.UsdWholesaleStep, &rule.UsdRetailStep, - &rule.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurWholesaleStep, &rule.EurRetailStep, + &rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryWholesaleStep, &rule.TryRetailStep, &rule.TryRetailMode, + &rule.UsdBase, &rule.Usd1, &rule.Usd2, &rule.Usd3, &rule.Usd4, &rule.Usd5, &rule.Usd6, &rule.UsdWholesaleStep, &rule.UsdRetailStep, &rule.UsdRetailMode, + &rule.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurWholesaleStep, &rule.EurRetailStep, &rule.EurRetailMode, ); err != nil { return nil, err } + rule.TryRetailMode = normalizeRetailMode(rule.TryRetailMode) + rule.UsdRetailMode = normalizeRetailMode(rule.UsdRetailMode) + rule.EurRetailMode = normalizeRetailMode(rule.EurRetailMode) rule.PricingParameterID = item.PricingParameterID rule.AskiliYan = pricingParameterScopeValue(item.AskiliYan) rule.Kategori = pricingParameterScopeValue(item.Kategori) @@ -809,9 +844,7 @@ ORDER BY rule.BrandCode = pricingParameterScopeValue(item.BrandCode) rule.BrandGroupSec = pricingParameterScopeValue(item.BrandGroupSec) item.HasRule = strings.TrimSpace(rule.ID) != "" - if item.HasRule { - item.Rule = &rule - } + item.Rule = &rule out = append(out, item) } return out, rows.Err() diff --git a/svc/queries/pricing_rules.go b/svc/queries/pricing_rules.go index 3fbf0eb..c9cb46d 100644 --- a/svc/queries/pricing_rules.go +++ b/svc/queries/pricing_rules.go @@ -3,6 +3,7 @@ package queries import ( "context" "database/sql" + "encoding/json" "fmt" "strconv" "strings" @@ -15,6 +16,22 @@ import ( // - mk_pricex: per-currency multipliers (base + 1..6). // - mk_priceroll: per-currency rounding steps for wholesale (1-5) and retail (6+). +func normalizeRetailMode(v string) string { + v = strings.ToUpper(strings.TrimSpace(v)) + switch v { + case "", "STEP": + return "STEP" + case "END_99", "END_49", "BAND_99", "BAND_49": + return v + default: + return "STEP" + } +} + +func NormalizeRetailModeForRoute(v string) string { + return normalizeRetailMode(v) +} + func EnsurePricingRuleTables(pg *sql.DB) error { stmts := []string{ ` @@ -32,10 +49,26 @@ CREATE TABLE IF NOT EXISTS mk_pricing_rule ( brand_code TEXT[] NOT NULL DEFAULT '{}'::text[], brand_group TEXT[] NOT NULL DEFAULT '{}'::text[], + strategy_code TEXT NOT NULL DEFAULT 'CORE', + anchor_mode TEXT NOT NULL DEFAULT 'USD', + calc_enabled BOOLEAN NOT NULL DEFAULT TRUE, + publish_postgres BOOLEAN NOT NULL DEFAULT TRUE, + publish_nebim BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() )`, + `ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS strategy_code TEXT NOT NULL DEFAULT 'CORE'`, + `ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS anchor_mode TEXT NOT NULL DEFAULT 'USD'`, + `ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS calc_enabled BOOLEAN NOT NULL DEFAULT TRUE`, + `ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS publish_postgres BOOLEAN NOT NULL DEFAULT TRUE`, + `ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS publish_nebim BOOLEAN NOT NULL DEFAULT TRUE`, + `UPDATE mk_pricing_rule SET strategy_code='CORE' WHERE COALESCE(NULLIF(BTRIM(strategy_code), ''), '') = ''`, + `UPDATE mk_pricing_rule SET anchor_mode='USD' WHERE COALESCE(NULLIF(BTRIM(anchor_mode), ''), '') = ''`, + `ALTER TABLE mk_pricing_rule DROP CONSTRAINT IF EXISTS ck_mk_pricing_rule_strategy_code`, + `ALTER TABLE mk_pricing_rule ADD CONSTRAINT ck_mk_pricing_rule_strategy_code CHECK (strategy_code IN ('CORE','PREMIUM','SARTORIAL'))`, + `ALTER TABLE mk_pricing_rule DROP CONSTRAINT IF EXISTS ck_mk_pricing_rule_anchor_mode`, + `ALTER TABLE mk_pricing_rule ADD CONSTRAINT ck_mk_pricing_rule_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))`, `CREATE INDEX IF NOT EXISTS ix_mk_pricing_rule_active ON mk_pricing_rule (is_active)`, ` CREATE TABLE IF NOT EXISTS mk_pricex ( @@ -60,13 +93,16 @@ CREATE TABLE IF NOT EXISTS mk_priceroll ( step NUMERIC(18,6) NOT NULL DEFAULT 0, wholesale_step NUMERIC(18,6) NOT NULL DEFAULT 0, retail_step NUMERIC(18,6) NOT NULL DEFAULT 0, + retail_mode TEXT NOT NULL DEFAULT 'STEP', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (rule_id, currency) )`, `ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS wholesale_step NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS retail_step NUMERIC(18,6) NOT NULL DEFAULT 0`, + `ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS retail_mode TEXT NOT NULL DEFAULT 'STEP'`, `UPDATE mk_priceroll SET wholesale_step = step, retail_step = step WHERE step <> 0 AND wholesale_step = 0 AND retail_step = 0`, + `UPDATE mk_priceroll SET retail_mode='STEP' WHERE COALESCE(NULLIF(BTRIM(retail_mode), ''), '') = ''`, `CREATE INDEX IF NOT EXISTS ix_mk_priceroll_currency ON mk_priceroll (currency)`, } for _, s := range stmts { @@ -92,7 +128,12 @@ type PricingRuleRow struct { BrandCode []string `json:"brand_code"` 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 TryBase float64 `json:"try_base"` @@ -104,6 +145,7 @@ type PricingRuleRow struct { Try6 float64 `json:"try6"` TryWholesaleStep float64 `json:"try_wholesale_step"` TryRetailStep float64 `json:"try_retail_step"` + TryRetailMode string `json:"try_retail_mode"` UsdBase float64 `json:"usd_base"` Usd1 float64 `json:"usd1"` @@ -114,6 +156,7 @@ type PricingRuleRow struct { Usd6 float64 `json:"usd6"` UsdWholesaleStep float64 `json:"usd_wholesale_step"` UsdRetailStep float64 `json:"usd_retail_step"` + UsdRetailMode string `json:"usd_retail_mode"` EurBase float64 `json:"eur_base"` Eur1 float64 `json:"eur1"` @@ -124,6 +167,7 @@ type PricingRuleRow struct { Eur6 float64 `json:"eur6"` EurWholesaleStep float64 `json:"eur_wholesale_step"` EurRetailStep float64 `json:"eur_retail_step"` + EurRetailMode string `json:"eur_retail_mode"` } type PricingRuleSaveItem struct { @@ -141,7 +185,12 @@ type PricingRuleSaveItem struct { BrandCode []string `json:"brand_code"` 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"` Try1 float64 `json:"try1"` @@ -152,6 +201,7 @@ type PricingRuleSaveItem struct { Try6 float64 `json:"try6"` TryWholesaleStep float64 `json:"try_wholesale_step"` TryRetailStep float64 `json:"try_retail_step"` + TryRetailMode string `json:"try_retail_mode"` UsdBase float64 `json:"usd_base"` Usd1 float64 `json:"usd1"` @@ -162,6 +212,7 @@ type PricingRuleSaveItem struct { Usd6 float64 `json:"usd6"` UsdWholesaleStep float64 `json:"usd_wholesale_step"` UsdRetailStep float64 `json:"usd_retail_step"` + UsdRetailMode string `json:"usd_retail_mode"` EurBase float64 `json:"eur_base"` Eur1 float64 `json:"eur1"` @@ -172,6 +223,174 @@ type PricingRuleSaveItem struct { Eur6 float64 `json:"eur6"` EurWholesaleStep float64 `json:"eur_wholesale_step"` EurRetailStep float64 `json:"eur_retail_step"` + EurRetailMode string `json:"eur_retail_mode"` +} + +// BulkSavePricingRulesFast persists multipliers + rounding steps in a set-based way. +// This is intentionally "dumb": it updates/creates a mk_pricing_rule row (latest by pricing_parameter_id) +// and upserts mk_pricex/mk_priceroll for TRY/USD/EUR. +func BulkSavePricingRulesFast(ctx context.Context, tx *sql.Tx, items []PricingRuleSaveItem) (int, error) { + if len(items) == 0 { + return 0, nil + } + + raw, err := json.Marshal(items) + if err != nil { + return 0, err + } + + // Notes: + // - rule_id resolution: + // 1) explicit id (if provided) + // 2) latest rule for pricing_parameter_id (if provided) + // 3) otherwise new UUID + // - mk_pricing_rule has no unique constraint on pricing_parameter_id by design, so we target "latest" row. + // - created_at uses default; updated_at is bumped on every save. + q := ` +WITH input AS ( + SELECT * + FROM jsonb_to_recordset($1::jsonb) AS x( + id text, + pricing_parameter_id bigint, + calc_enabled boolean, + publish_postgres boolean, + publish_nebim boolean, + is_active boolean, + try_retail_mode text, + usd_retail_mode text, + eur_retail_mode text, + + try_base float8, try1 float8, try2 float8, try3 float8, try4 float8, try5 float8, try6 float8, + try_wholesale_step float8, try_retail_step float8, + + usd_base float8, usd1 float8, usd2 float8, usd3 float8, usd4 float8, usd5 float8, usd6 float8, + usd_wholesale_step float8, usd_retail_step float8, + + eur_base float8, eur1 float8, eur2 float8, eur3 float8, eur4 float8, eur5 float8, eur6 float8, + eur_wholesale_step float8, eur_retail_step float8 + ) +), +norm AS ( + SELECT + NULLIF(BTRIM(id), '') AS id_txt, + COALESCE(pricing_parameter_id, 0) AS pricing_parameter_id, + COALESCE(calc_enabled, TRUE) AS calc_enabled, + COALESCE(publish_postgres, TRUE) AS publish_postgres, + COALESCE(publish_nebim, TRUE) AS publish_nebim, + COALESCE(is_active, TRUE) AS is_active, + COALESCE(NULLIF(UPPER(BTRIM(try_retail_mode)), ''), 'STEP') AS try_retail_mode, + COALESCE(NULLIF(UPPER(BTRIM(usd_retail_mode)), ''), 'STEP') AS usd_retail_mode, + COALESCE(NULLIF(UPPER(BTRIM(eur_retail_mode)), ''), 'STEP') AS eur_retail_mode, + + COALESCE(try_base, 0) AS try_base, COALESCE(try1, 0) AS try1, COALESCE(try2, 0) AS try2, COALESCE(try3, 0) AS try3, COALESCE(try4, 0) AS try4, COALESCE(try5, 0) AS try5, COALESCE(try6, 0) AS try6, + COALESCE(try_wholesale_step, 0) AS try_wholesale_step, COALESCE(try_retail_step, 0) AS try_retail_step, + + COALESCE(usd_base, 0) AS usd_base, COALESCE(usd1, 0) AS usd1, COALESCE(usd2, 0) AS usd2, COALESCE(usd3, 0) AS usd3, COALESCE(usd4, 0) AS usd4, COALESCE(usd5, 0) AS usd5, COALESCE(usd6, 0) AS usd6, + COALESCE(usd_wholesale_step, 0) AS usd_wholesale_step, COALESCE(usd_retail_step, 0) AS usd_retail_step, + + COALESCE(eur_base, 0) AS eur_base, COALESCE(eur1, 0) AS eur1, COALESCE(eur2, 0) AS eur2, COALESCE(eur3, 0) AS eur3, COALESCE(eur4, 0) AS eur4, COALESCE(eur5, 0) AS eur5, COALESCE(eur6, 0) AS eur6, + COALESCE(eur_wholesale_step, 0) AS eur_wholesale_step, COALESCE(eur_retail_step, 0) AS eur_retail_step + FROM input +), +resolved AS ( + SELECT + COALESCE( + NULLIF(id_txt, '')::uuid, + latest.id, + gen_random_uuid() + ) AS rule_id, + pricing_parameter_id, + calc_enabled, + publish_postgres, + publish_nebim, + is_active, + try_retail_mode, + usd_retail_mode, + eur_retail_mode, + + try_base, try1, try2, try3, try4, try5, try6, + try_wholesale_step, try_retail_step, + usd_base, usd1, usd2, usd3, usd4, usd5, usd6, + usd_wholesale_step, usd_retail_step, + eur_base, eur1, eur2, eur3, eur4, eur5, eur6, + eur_wholesale_step, eur_retail_step + FROM norm n + LEFT JOIN LATERAL ( + SELECT r.id + FROM mk_pricing_rule r + WHERE r.pricing_parameter_id = n.pricing_parameter_id + ORDER BY r.created_at DESC, r.updated_at DESC, r.id DESC + LIMIT 1 + ) latest ON (n.id_txt IS NULL AND n.pricing_parameter_id > 0) +), +upsert_rule AS ( + INSERT INTO mk_pricing_rule ( + id, + pricing_parameter_id, + calc_enabled, + publish_postgres, + publish_nebim, + is_active, + updated_at + ) + SELECT + rule_id, + NULLIF(pricing_parameter_id, 0), + calc_enabled, + publish_postgres, + publish_nebim, + is_active, + now() + FROM resolved + ON CONFLICT (id) DO UPDATE SET + pricing_parameter_id = EXCLUDED.pricing_parameter_id, + calc_enabled = EXCLUDED.calc_enabled, + publish_postgres = EXCLUDED.publish_postgres, + publish_nebim = EXCLUDED.publish_nebim, + is_active = EXCLUDED.is_active, + updated_at = now() + RETURNING id +), +upsert_pricex AS ( + INSERT INTO mk_pricex (rule_id, currency, base_mult, m1, m2, m3, m4, m5, m6, updated_at) + SELECT rule_id, 'TRY', try_base, try1, try2, try3, try4, try5, try6, now() FROM resolved + UNION ALL + SELECT rule_id, 'USD', usd_base, usd1, usd2, usd3, usd4, usd5, usd6, now() FROM resolved + UNION ALL + SELECT rule_id, 'EUR', eur_base, eur1, eur2, eur3, eur4, eur5, eur6, now() FROM resolved + ON CONFLICT (rule_id, currency) DO UPDATE SET + base_mult = EXCLUDED.base_mult, + m1 = EXCLUDED.m1, + m2 = EXCLUDED.m2, + m3 = EXCLUDED.m3, + m4 = EXCLUDED.m4, + m5 = EXCLUDED.m5, + m6 = EXCLUDED.m6, + updated_at = now() + RETURNING 1 +), +upsert_priceroll AS ( + INSERT INTO mk_priceroll (rule_id, currency, wholesale_step, retail_step, retail_mode, updated_at) + SELECT rule_id, 'TRY', try_wholesale_step, try_retail_step, try_retail_mode, now() FROM resolved + UNION ALL + SELECT rule_id, 'USD', usd_wholesale_step, usd_retail_step, usd_retail_mode, now() FROM resolved + UNION ALL + SELECT rule_id, 'EUR', eur_wholesale_step, eur_retail_step, eur_retail_mode, now() FROM resolved + ON CONFLICT (rule_id, currency) DO UPDATE SET + wholesale_step = EXCLUDED.wholesale_step, + retail_step = EXCLUDED.retail_step, + retail_mode = EXCLUDED.retail_mode, + updated_at = now() + RETURNING 1 +) +SELECT COUNT(*)::int FROM resolved; +` + + var updated int + if err := tx.QueryRowContext(ctx, q, raw).Scan(&updated); err != nil { + return 0, err + } + return updated, nil } func ListPricingRules(ctx context.Context, pg *sql.DB) ([]PricingRuleRow, error) { @@ -190,6 +409,11 @@ SELECT r.marka, r.brand_code, r.brand_group, + r.strategy_code, + r.anchor_mode, + r.calc_enabled, + r.publish_postgres, + r.publish_nebim, r.is_active, COALESCE(tx.base_mult, 0)::float8 AS try_base, @@ -201,6 +425,7 @@ SELECT COALESCE(tx.m6, 0)::float8 AS try6, COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8 AS try_wholesale_step, COALESCE(NULLIF(tr.retail_step, 0), tr.step, 0)::float8 AS try_retail_step, + COALESCE(NULLIF(BTRIM(tr.retail_mode), ''), 'STEP') AS try_retail_mode, COALESCE(ux.base_mult, 0)::float8 AS usd_base, COALESCE(ux.m1, 0)::float8 AS usd1, @@ -211,6 +436,7 @@ SELECT COALESCE(ux.m6, 0)::float8 AS usd6, COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8 AS usd_wholesale_step, COALESCE(NULLIF(ur.retail_step, 0), ur.step, 0)::float8 AS usd_retail_step, + COALESCE(NULLIF(BTRIM(ur.retail_mode), ''), 'STEP') AS usd_retail_mode, COALESCE(ex.base_mult, 0)::float8 AS eur_base, COALESCE(ex.m1, 0)::float8 AS eur1, @@ -220,7 +446,8 @@ SELECT COALESCE(ex.m5, 0)::float8 AS eur5, COALESCE(ex.m6, 0)::float8 AS eur6, COALESCE(NULLIF(er.wholesale_step, 0), er.step, 0)::float8 AS eur_wholesale_step, - COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8 AS eur_retail_step + COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8 AS eur_retail_step, + COALESCE(NULLIF(BTRIM(er.retail_mode), ''), 'STEP') AS eur_retail_mode FROM mk_pricing_rule r LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY' LEFT JOIN mk_pricex ux ON ux.rule_id = r.id AND ux.currency='USD' @@ -252,14 +479,22 @@ ORDER BY r.created_at DESC; pq.Array(&r.Marka), pq.Array(&r.BrandCode), pq.Array(&r.BrandGroupSec), + &r.StrategyCode, + &r.AnchorMode, + &r.CalcEnabled, + &r.PublishPostgres, + &r.PublishNebim, &r.IsActive, - &r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryWholesaleStep, &r.TryRetailStep, - &r.UsdBase, &r.Usd1, &r.Usd2, &r.Usd3, &r.Usd4, &r.Usd5, &r.Usd6, &r.UsdWholesaleStep, &r.UsdRetailStep, - &r.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurWholesaleStep, &r.EurRetailStep, + &r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryWholesaleStep, &r.TryRetailStep, &r.TryRetailMode, + &r.UsdBase, &r.Usd1, &r.Usd2, &r.Usd3, &r.Usd4, &r.Usd5, &r.Usd6, &r.UsdWholesaleStep, &r.UsdRetailStep, &r.UsdRetailMode, + &r.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurWholesaleStep, &r.EurRetailStep, &r.EurRetailMode, ); err != nil { return nil, err } + r.TryRetailMode = normalizeRetailMode(r.TryRetailMode) + r.UsdRetailMode = normalizeRetailMode(r.UsdRetailMode) + r.EurRetailMode = normalizeRetailMode(r.EurRetailMode) out = append(out, r) } return out, rows.Err() @@ -282,6 +517,42 @@ func normalizeTextList(in []string) []string { return out } +func deriveStrategyCodeFromBrandGroup(values []string) string { + for _, value := range values { + normalized := strings.ToUpper(strings.TrimSpace(value)) + switch normalized { + case "CORE", "PREMIUM", "SARTORIAL": + return normalized + } + } + return "CORE" +} + +func deriveAnchorModeFromBrandGroup(ctx context.Context, tx *sql.Tx, values []string) string { + for _, value := range values { + normalized := strings.TrimSpace(value) + if normalized == "" { + continue + } + var mode string + err := tx.QueryRowContext(ctx, ` +SELECT anchor_mode +FROM mk_brandgrp +WHERE UPPER(BTRIM(code)) = UPPER(BTRIM($1)) + OR UPPER(BTRIM(title)) = UPPER(BTRIM($1)) +ORDER BY id +LIMIT 1 +`, normalized).Scan(&mode) + if err == nil { + mode = strings.ToUpper(strings.TrimSpace(mode)) + if mode == "TRY" || mode == "USD" { + return mode + } + } + } + return "USD" +} + // UpsertPricingRule persists rule scope + per-currency multipliers/roundings. // Parameter-backed worksheet saves append a new rule version so older prices // remain queryable. Legacy rules without a parameter id keep update behavior. @@ -306,6 +577,11 @@ func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem item.Marka = normalizeTextList(item.Marka) item.BrandCode = normalizeTextList(item.BrandCode) item.BrandGroupSec = normalizeTextList(item.BrandGroupSec) + item.StrategyCode = deriveStrategyCodeFromBrandGroup(item.BrandGroupSec) + item.AnchorMode = deriveAnchorModeFromBrandGroup(ctx, tx, item.BrandGroupSec) + item.TryRetailMode = normalizeRetailMode(item.TryRetailMode) + item.UsdRetailMode = normalizeRetailMode(item.UsdRetailMode) + item.EurRetailMode = normalizeRetailMode(item.EurRetailMode) id := strings.TrimSpace(item.ID) if item.PricingParameterID > 0 { @@ -317,12 +593,15 @@ func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem INSERT INTO mk_pricing_rule ( pricing_parameter_id, askili_yan,kategori,urun_ilk_grubu,urun_ana_grubu,urun_alt_grubu, - icerik,karisim,marka,brand_code,brand_group,is_active,created_at,updated_at + icerik,karisim,marka,brand_code,brand_group, + strategy_code,anchor_mode,calc_enabled,publish_postgres,publish_nebim, + is_active,created_at,updated_at ) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now()) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,now(),now()) RETURNING id `, nullablePricingParameterID(item.PricingParameterID), pq.Array(item.AskiliYan), pq.Array(item.Kategori), pq.Array(item.UrunIlkGrubu), pq.Array(item.UrunAnaGrubu), pq.Array(item.UrunAltGrubu), pq.Array(item.Icerik), pq.Array(item.Karisim), pq.Array(item.Marka), pq.Array(item.BrandCode), pq.Array(item.BrandGroupSec), + item.StrategyCode, item.AnchorMode, item.CalcEnabled, item.PublishPostgres, item.PublishNebim, item.IsActive, ).Scan(&id); err != nil { return "", err @@ -341,13 +620,19 @@ UPDATE mk_pricing_rule SET marka=$10, brand_code=$11, brand_group=$12, - is_active=$13, + strategy_code=$13, + anchor_mode=$14, + calc_enabled=$15, + publish_postgres=$16, + publish_nebim=$17, + is_active=$18, updated_at=now() WHERE id=$1 `, id, nullablePricingParameterID(item.PricingParameterID), pq.Array(item.AskiliYan), pq.Array(item.Kategori), pq.Array(item.UrunIlkGrubu), pq.Array(item.UrunAnaGrubu), pq.Array(item.UrunAltGrubu), pq.Array(item.Icerik), pq.Array(item.Karisim), pq.Array(item.Marka), pq.Array(item.BrandCode), pq.Array(item.BrandGroupSec), + item.StrategyCode, item.AnchorMode, item.CalcEnabled, item.PublishPostgres, item.PublishNebim, item.IsActive, ); err != nil { return "", err @@ -371,41 +656,176 @@ ON CONFLICT (rule_id, currency) DO UPDATE SET `, id, cur, base, m1, m2, m3, m4, m5, m6) return err } - upsertRoll := func(cur string, wholesaleStep, retailStep float64) error { + upsertRoll := func(cur string, wholesaleStep, retailStep float64, retailMode string) error { _, err := tx.ExecContext(ctx, ` -INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, created_at, updated_at) -VALUES ($1,$2,$3,$4,$5,now(),now()) +INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, retail_mode, created_at, updated_at) +VALUES ($1,$2,$3,$4,$5,$6,now(),now()) ON CONFLICT (rule_id, currency) DO UPDATE SET step=EXCLUDED.step, wholesale_step=EXCLUDED.wholesale_step, retail_step=EXCLUDED.retail_step, + retail_mode=EXCLUDED.retail_mode, updated_at=now() -`, id, cur, wholesaleStep, wholesaleStep, retailStep) +`, id, cur, wholesaleStep, wholesaleStep, retailStep, retailMode) return err } if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil { return "", err } - if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep); err != nil { + if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep, item.TryRetailMode); err != nil { return "", err } if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil { return "", err } - if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep); err != nil { + if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep, item.UsdRetailMode); err != nil { return "", err } if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil { return "", err } - if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep); err != nil { + if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep, item.EurRetailMode); err != nil { return "", err } return id, nil } +// UpdatePricingRuleByIDFast updates an existing rule without parameter versioning/scope fill. +// This is the fast path for worksheet saves where rule_id is already known. +func UpdatePricingRuleByIDFast(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem) error { + if tx == nil { + return fmt.Errorf("nil tx") + } + ruleID := strings.TrimSpace(item.ID) + if ruleID == "" { + return fmt.Errorf("missing rule id") + } + + item.TryRetailMode = normalizeRetailMode(item.TryRetailMode) + item.UsdRetailMode = normalizeRetailMode(item.UsdRetailMode) + item.EurRetailMode = normalizeRetailMode(item.EurRetailMode) + + if _, err := tx.ExecContext(ctx, ` +UPDATE mk_pricing_rule SET + calc_enabled=$2, + publish_postgres=$3, + publish_nebim=$4, + is_active=$5, + updated_at=now() +WHERE id=$1 +`, ruleID, item.CalcEnabled, item.PublishPostgres, item.PublishNebim, item.IsActive); err != nil { + return err + } + + upsertX := func(cur string, base, m1, m2, m3, m4, m5, m6 float64) error { + _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_pricex (rule_id, currency, base_mult, m1, m2, m3, m4, m5, m6, created_at, updated_at) +VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,$7,$8,$9,now(),now()) +ON CONFLICT (rule_id, currency) DO UPDATE SET + base_mult=EXCLUDED.base_mult, + m1=EXCLUDED.m1, + m2=EXCLUDED.m2, + m3=EXCLUDED.m3, + m4=EXCLUDED.m4, + m5=EXCLUDED.m5, + m6=EXCLUDED.m6, + updated_at=now() +`, ruleID, cur, base, m1, m2, m3, m4, m5, m6) + return err + } + + upsertRoll := func(cur string, wholesaleStep, retailStep float64, retailMode string) error { + _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, retail_mode, created_at, updated_at) +VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,now(),now()) +ON CONFLICT (rule_id, currency) DO UPDATE SET + step=EXCLUDED.step, + wholesale_step=EXCLUDED.wholesale_step, + retail_step=EXCLUDED.retail_step, + retail_mode=EXCLUDED.retail_mode, + updated_at=now() +`, ruleID, cur, wholesaleStep, wholesaleStep, retailStep, retailMode) + return err + } + + if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil { + return err + } + if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep, item.TryRetailMode); err != nil { + return err + } + if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil { + return err + } + if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep, item.UsdRetailMode); err != nil { + return err + } + if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil { + return err + } + if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep, item.EurRetailMode); err != nil { + return err + } + + return nil +} + +// UpsertPricingRuleByParameterIDFast ensures there is a rule row for a pricing_parameter_id and +// updates its multipliers/roundings in place. This avoids expensive parameter versioning and +// scope fill during worksheet-style bulk saves. +func UpsertPricingRuleByParameterIDFast(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem) (string, error) { + if tx == nil { + return "", fmt.Errorf("nil tx") + } + if item.PricingParameterID <= 0 { + return "", fmt.Errorf("missing pricing_parameter_id") + } + + // Find latest rule for this parameter id (if any). + var ruleID string + _ = tx.QueryRowContext(ctx, ` +SELECT id::text +FROM mk_pricing_rule +WHERE pricing_parameter_id = $1 +ORDER BY created_at DESC, updated_at DESC, id DESC +LIMIT 1 +FOR UPDATE +`, item.PricingParameterID).Scan(&ruleID) + ruleID = strings.TrimSpace(ruleID) + + if ruleID == "" { + // Create minimal rule row; other fields have defaults and parameter scope is read from mk_urunpricingprmtr. + if err := tx.QueryRowContext(ctx, ` +INSERT INTO mk_pricing_rule ( + pricing_parameter_id, + calc_enabled, + publish_postgres, + publish_nebim, + is_active, + created_at, + updated_at +) +VALUES ($1,$2,$3,$4,$5,now(),now()) +RETURNING id::text +`, item.PricingParameterID, item.CalcEnabled, item.PublishPostgres, item.PublishNebim, item.IsActive).Scan(&ruleID); err != nil { + return "", err + } + ruleID = strings.TrimSpace(ruleID) + } + if ruleID == "" { + return "", fmt.Errorf("failed to resolve rule id") + } + + // Reuse the ID-fast updater now that we have an id. + item.ID = ruleID + if err := UpdatePricingRuleByIDFast(ctx, tx, item); err != nil { + return "", err + } + return ruleID, nil +} + func nullablePricingParameterID(id int64) any { if id <= 0 { return nil diff --git a/svc/queries/product_pricing.go b/svc/queries/product_pricing.go index ece69f1..578dd24 100644 --- a/svc/queries/product_pricing.go +++ b/svc/queries/product_pricing.go @@ -115,6 +115,11 @@ func GetAllProductPricingRows(ctx context.Context, chunkSize int, filters Produc orderExpr = "rc.ProductCode" orderDir = "ASC" } + orderBySQL := orderExpr + ` ` + orderDir + if !strings.EqualFold(strings.TrimSpace(orderExpr), "rc.ProductCode") { + orderBySQL += `, + rc.ProductCode ASC` + } baseQuery := ` IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes; @@ -230,8 +235,7 @@ func GetAllProductPricingRows(ctx context.Context, chunkSize int, filters Produc LEFT JOIN #disp_base db ON db.ItemCode = rc.ProductCode ORDER BY - ` + orderExpr + ` ` + orderDir + `, - rc.ProductCode ASC; + ` + orderBySQL + `; ` rows, err := db.MssqlDB.QueryContext(ctx, baseQuery, args...) @@ -740,6 +744,11 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro orderExpr = "rc.ProductCode" orderDir = "ASC" } + orderBySQL := orderExpr + ` ` + orderDir + if !strings.EqualFold(strings.TrimSpace(orderExpr), "rc.ProductCode") { + orderBySQL += `, + rc.ProductCode ASC` + } productQuery := ` IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes; IF OBJECT_ID('tempdb..#stock_base') IS NOT NULL DROP TABLE #stock_base; @@ -806,8 +815,7 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro LEFT JOIN #stock_base sb ON sb.ItemCode = rc.ProductCode ORDER BY - ` + orderExpr + ` ` + orderDir + `, - rc.ProductCode ASC + ` + orderBySQL + ` OFFSET ` + strconv.Itoa(offset) + ` ROWS FETCH NEXT ` + strconv.Itoa(limit) + ` ROWS ONLY; ` diff --git a/svc/queries/product_pricing_dims_mssql.go b/svc/queries/product_pricing_dims_mssql.go new file mode 100644 index 0000000..f292dfe --- /dev/null +++ b/svc/queries/product_pricing_dims_mssql.go @@ -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,''))); +` diff --git a/svc/queries/product_pricing_fx_publish.go b/svc/queries/product_pricing_fx_publish.go new file mode 100644 index 0000000..7146425 --- /dev/null +++ b/svc/queries/product_pricing_fx_publish.go @@ -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 +} diff --git a/svc/queries/product_pricing_recalc_queue.go b/svc/queries/product_pricing_recalc_queue.go new file mode 100644 index 0000000..33d23e6 --- /dev/null +++ b/svc/queries/product_pricing_recalc_queue.go @@ -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 +} diff --git a/svc/routes/brand_group_currency.go b/svc/routes/brand_group_currency.go new file mode 100644 index 0000000..7dee7e3 --- /dev/null +++ b/svc/routes/brand_group_currency.go @@ -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}) + } +} diff --git a/svc/routes/pricing_rules.go b/svc/routes/pricing_rules.go index 12f9f05..d78f143 100644 --- a/svc/routes/pricing_rules.go +++ b/svc/routes/pricing_rules.go @@ -1,11 +1,13 @@ package routes import ( + "bssapp-backend/auth" "bssapp-backend/queries" "bssapp-backend/utils" "database/sql" "encoding/json" "fmt" + "github.com/lib/pq" "net/http" "sort" "strconv" @@ -33,6 +35,11 @@ type PricingRuleImportItem struct { Marka string `json:"marka"` BrandCode string `json:"brand_code"` BrandGroupSec string `json:"brand_group"` + StrategyCode string `json:"strategy_code"` + AnchorMode string `json:"anchor_mode"` + CalcEnabled bool `json:"calc_enabled"` + PublishPostgres bool `json:"publish_postgres"` + PublishNebim bool `json:"publish_nebim"` IsActive bool `json:"is_active"` TryBase float64 `json:"try_base"` Try1 float64 `json:"try1"` @@ -43,6 +50,7 @@ type PricingRuleImportItem struct { Try6 float64 `json:"try6"` TryWholesaleStep float64 `json:"try_wholesale_step"` TryRetailStep float64 `json:"try_retail_step"` + TryRetailMode string `json:"try_retail_mode"` UsdBase float64 `json:"usd_base"` Usd1 float64 `json:"usd1"` Usd2 float64 `json:"usd2"` @@ -52,6 +60,7 @@ type PricingRuleImportItem struct { Usd6 float64 `json:"usd6"` UsdWholesaleStep float64 `json:"usd_wholesale_step"` UsdRetailStep float64 `json:"usd_retail_step"` + UsdRetailMode string `json:"usd_retail_mode"` EurBase float64 `json:"eur_base"` Eur1 float64 `json:"eur1"` Eur2 float64 `json:"eur2"` @@ -61,6 +70,7 @@ type PricingRuleImportItem struct { Eur6 float64 `json:"eur6"` EurWholesaleStep float64 `json:"eur_wholesale_step"` EurRetailStep float64 `json:"eur_retail_step"` + EurRetailMode string `json:"eur_retail_mode"` } type PricingRuleImportPayload struct { @@ -77,6 +87,52 @@ type PricingRuleImportResult struct { ErrorCount int `json:"error_count"` } +func normalizePricingStrategyCode(v string) string { + v = strings.ToUpper(strings.TrimSpace(v)) + if v == "" { + return "CORE" + } + return v +} + +func normalizePricingAnchorMode(v string) string { + v = strings.ToUpper(strings.TrimSpace(v)) + if v == "" { + return "USD" + } + return v +} + +func isValidPricingStrategyCode(v string) bool { + if strings.TrimSpace(v) == "" { + return true + } + switch normalizePricingStrategyCode(v) { + case "CORE", "PREMIUM", "SARTORIAL": + return true + default: + return false + } +} + +func isValidPricingAnchorMode(v string) bool { + switch normalizePricingAnchorMode(v) { + case "TRY", "USD": + return true + default: + return false + } +} + +func isValidPricingRetailMode(v string) bool { + switch queries.NormalizeRetailModeForRoute(v) { + case "STEP", "END_99", "END_49", "BAND_99", "BAND_49": + return true + default: + return false + } +} + func GetPricingRulesHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -104,37 +160,130 @@ func SavePricingRulesBulkHandler(pg *sql.DB) http.HandlerFunc { return } + started := time.Now() traceID := utils.TraceIDFromRequest(r) + w.Header().Set("X-Trace-ID", traceID) ctx := utils.ContextWithTraceID(r.Context(), traceID) + logger := utils.SlogFromContext(ctx).With("handler", "pricing-rules.bulk-save") + + claims, _ := auth.GetClaimsFromContext(ctx) + if claims != nil { + logger = logger.With("user", claims.Username, "user_id", claims.ID) + } + existingIDCount := 0 + newIDCount := 0 + for _, it := range payload.Items { + if strings.TrimSpace(it.ID) != "" { + existingIDCount++ + } else { + newIDCount++ + } + } + logger.Info("bulk-save:start", + "items", len(payload.Items), + "existing_id", existingIDCount, + "new_id", newIDCount, + ) tx, err := pg.BeginTx(ctx, nil) if err != nil { + logger.Error("bulk-save:tx-begin:error", "err", err) http.Error(w, "pg transaction start error", http.StatusInternalServerError) return } defer tx.Rollback() + // Serialize writes touching mk_urunpricingprmtr/mk_pricing_rule/mk_pricex/mk_priceroll + // to avoid deadlocks with pricing-parameter sync and concurrent bulk-saves. + lockWaitStarted := time.Now() + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil { + logger.Error("bulk-save:advisory-lock:error", "err", err) + http.Error(w, "pg advisory lock error", http.StatusInternalServerError) + return + } + logger.Info("bulk-save:advisory-lock:acquired", "wait_ms", time.Since(lockWaitStarted).Milliseconds()) + + logPgErr := func(msg string, err error, it queries.PricingRuleSaveItem) { + fields := []any{ + "pricing_parameter_id", it.PricingParameterID, + "id", strings.TrimSpace(it.ID), + "err", err, + } + if pe, ok := err.(*pq.Error); ok && pe != nil { + fields = append(fields, + "sqlstate", string(pe.Code), + "constraint", pe.Constraint, + "table", pe.Table, + "column", pe.Column, + "detail", pe.Detail, + "where", pe.Where, + ) + } + logger.Error(msg, fields...) + } + updated := 0 for _, it := range payload.Items { // Zero means that no rounding rule has been configured yet. if it.TryWholesaleStep < 0 || it.TryRetailStep < 0 || it.UsdWholesaleStep < 0 || it.UsdRetailStep < 0 || it.EurWholesaleStep < 0 || it.EurRetailStep < 0 { + logger.Warn("bulk-save:invalid-rounding-step", + "pricing_parameter_id", it.PricingParameterID, + "id", strings.TrimSpace(it.ID), + ) http.Error(w, "invalid rounding step", http.StatusBadRequest) return } - id, err := queries.UpsertPricingRule(ctx, tx, it) - if err != nil { - http.Error(w, "pricing rule save error", http.StatusInternalServerError) + if !isValidPricingStrategyCode(it.StrategyCode) { + logger.Warn("bulk-save:invalid-strategy-code", + "pricing_parameter_id", it.PricingParameterID, + "id", strings.TrimSpace(it.ID), + "strategy_code", it.StrategyCode, + ) + http.Error(w, "invalid strategy_code", http.StatusBadRequest) return } - if id != "" { - updated++ + if !isValidPricingAnchorMode(it.AnchorMode) { + logger.Warn("bulk-save:invalid-anchor-mode", + "pricing_parameter_id", it.PricingParameterID, + "id", strings.TrimSpace(it.ID), + "anchor_mode", it.AnchorMode, + ) + http.Error(w, "invalid anchor_mode", http.StatusBadRequest) + return + } + if !isValidPricingRetailMode(it.TryRetailMode) || !isValidPricingRetailMode(it.UsdRetailMode) || !isValidPricingRetailMode(it.EurRetailMode) { + logger.Warn("bulk-save:invalid-retail-mode", + "pricing_parameter_id", it.PricingParameterID, + "id", strings.TrimSpace(it.ID), + "try_retail_mode", it.TryRetailMode, + "usd_retail_mode", it.UsdRetailMode, + "eur_retail_mode", it.EurRetailMode, + ) + http.Error(w, "invalid retail_mode", http.StatusBadRequest) + return } } + dbStarted := time.Now() + updated, err = queries.BulkSavePricingRulesFast(ctx, tx, payload.Items) + if err != nil { + // best-effort: log first item context + if len(payload.Items) > 0 { + logPgErr("bulk-save:bulk-fast:error", err, payload.Items[0]) + } else { + logger.Error("bulk-save:bulk-fast:error", "err", err) + } + http.Error(w, "pricing rule save error: "+err.Error(), http.StatusInternalServerError) + return + } + logger.Info("bulk-save:db:done", "updated", updated, "duration_ms", time.Since(dbStarted).Milliseconds()) + if err := tx.Commit(); err != nil { + logger.Error("bulk-save:commit:error", "err", err) http.Error(w, "pg transaction commit error", http.StatusInternalServerError) return } + logger.Info("bulk-save:done", "updated", updated, "duration_ms", time.Since(started).Milliseconds()) _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "updated": updated}) } } @@ -163,6 +312,12 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc { } defer tx.Rollback() + // Same global lock as bulk-save: prevents deadlocks with concurrent updates/sync. + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil { + http.Error(w, "pg advisory lock error", http.StatusInternalServerError) + return + } + updated := 0 matched := 0 skipped := 0 @@ -171,6 +326,18 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc { http.Error(w, "invalid rounding step", http.StatusBadRequest) return } + if !isValidPricingStrategyCode(raw.StrategyCode) { + http.Error(w, "invalid strategy_code", http.StatusBadRequest) + return + } + if !isValidPricingAnchorMode(raw.AnchorMode) { + http.Error(w, "invalid anchor_mode", http.StatusBadRequest) + return + } + if !isValidPricingRetailMode(raw.TryRetailMode) || !isValidPricingRetailMode(raw.UsdRetailMode) || !isValidPricingRetailMode(raw.EurRetailMode) { + http.Error(w, "invalid retail_mode", http.StatusBadRequest) + return + } pricingParameterID, err := queries.FindActivePricingParameterByScope(ctx, tx, queries.PricingParameterRowForImport( raw.AskiliYan, @@ -195,6 +362,11 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc { _, err = queries.UpsertPricingRule(ctx, tx, queries.PricingRuleSaveItem{ PricingParameterID: pricingParameterID, + StrategyCode: normalizePricingStrategyCode(raw.StrategyCode), + AnchorMode: normalizePricingAnchorMode(raw.AnchorMode), + CalcEnabled: raw.CalcEnabled, + PublishPostgres: raw.PublishPostgres, + PublishNebim: raw.PublishNebim, IsActive: raw.IsActive, TryBase: raw.TryBase, Try1: raw.Try1, @@ -205,6 +377,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc { Try6: raw.Try6, TryWholesaleStep: raw.TryWholesaleStep, TryRetailStep: raw.TryRetailStep, + TryRetailMode: queries.NormalizeRetailModeForRoute(raw.TryRetailMode), UsdBase: raw.UsdBase, Usd1: raw.Usd1, Usd2: raw.Usd2, @@ -214,6 +387,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc { Usd6: raw.Usd6, UsdWholesaleStep: raw.UsdWholesaleStep, UsdRetailStep: raw.UsdRetailStep, + UsdRetailMode: queries.NormalizeRetailModeForRoute(raw.UsdRetailMode), EurBase: raw.EurBase, Eur1: raw.Eur1, Eur2: raw.Eur2, @@ -223,6 +397,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc { Eur6: raw.Eur6, EurWholesaleStep: raw.EurWholesaleStep, EurRetailStep: raw.EurRetailStep, + EurRetailMode: queries.NormalizeRetailModeForRoute(raw.EurRetailMode), }) if err != nil { http.Error(w, "pricing rule import error", http.StatusInternalServerError) @@ -470,7 +645,34 @@ func sortPricingRuleExportRows(rows []queries.PricingParameterRuleRow, sortBy st return boolRank(liActive) > boolRank(ljActive) } return boolRank(liActive) < boolRank(ljActive) - case "askili_yan", "kategori", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "icerik", "marka", "brand_code", "brand_group": + case "calc_enabled", "publish_postgres", "publish_nebim": + liValue, ljValue := false, false + if li.Rule != nil { + switch sortBy { + case "calc_enabled": + liValue = li.Rule.CalcEnabled + case "publish_postgres": + liValue = li.Rule.PublishPostgres + case "publish_nebim": + liValue = li.Rule.PublishNebim + } + } + if lj.Rule != nil { + switch sortBy { + case "calc_enabled": + ljValue = lj.Rule.CalcEnabled + case "publish_postgres": + ljValue = lj.Rule.PublishPostgres + case "publish_nebim": + ljValue = lj.Rule.PublishNebim + } + } + if desc { + return boolRank(liValue) > boolRank(ljValue) + } + return boolRank(liValue) < boolRank(ljValue) + case "askili_yan", "kategori", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "icerik", "marka", "brand_code", "brand_group", "anchor_mode", + "try_retail_mode", "usd_retail_mode", "eur_retail_mode": vi := pricingRuleStringValue(li, sortBy) vj := pricingRuleStringValue(lj, sortBy) if desc { @@ -515,6 +717,26 @@ func pricingRuleStringValue(row queries.PricingParameterRuleRow, field string) s return row.BrandCode case "brand_group": return row.BrandGroupSec + case "anchor_mode": + if row.Rule == nil { + return "USD" + } + return row.Rule.AnchorMode + case "try_retail_mode": + if row.Rule == nil { + return "STEP" + } + return queries.NormalizeRetailModeForRoute(row.Rule.TryRetailMode) + case "usd_retail_mode": + if row.Rule == nil { + return "STEP" + } + return queries.NormalizeRetailModeForRoute(row.Rule.UsdRetailMode) + case "eur_retail_mode": + if row.Rule == nil { + return "STEP" + } + return queries.NormalizeRetailModeForRoute(row.Rule.EurRetailMode) default: return "" } @@ -523,10 +745,10 @@ func pricingRuleStringValue(row queries.PricingParameterRuleRow, field string) s func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string { headers := []string{ "DURUM", "AKTIF", "ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU", - "ICERIK", "MARKA", "BRAND CODE", "MARKA GRUBU", - "TRY TOPTAN YUVARLAMA", "TRY PERAKENDE YUVARLAMA", "TRY TABAN", "TRY 1", "TRY 2", "TRY 3", "TRY 4", "TRY 5", "TRY 6", - "USD TOPTAN YUVARLAMA", "USD PERAKENDE YUVARLAMA", "USD TABAN", "USD 1", "USD 2", "USD 3", "USD 4", "USD 5", "USD 6", - "EUR TOPTAN YUVARLAMA", "EUR PERAKENDE YUVARLAMA", "EUR TABAN", "EUR 1", "EUR 2", "EUR 3", "EUR 4", "EUR 5", "EUR 6", + "ICERIK", "MARKA", "BRAND CODE", "MARKA GRUBU", "ANCHOR MODE", "HESAP AKTIF", "PG YAYIN", "NEBIM YAYIN", + "TRY TOPTAN YUVARLAMA", "TRY PERAKENDE MODU", "TRY PERAKENDE DEGERI", "TRY TABAN", "TRY 1", "TRY 2", "TRY 3", "TRY 4", "TRY 5", "TRY 6", + "USD TOPTAN YUVARLAMA", "USD PERAKENDE MODU", "USD PERAKENDE DEGERI", "USD TABAN", "USD 1", "USD 2", "USD 3", "USD 4", "USD 5", "USD 6", + "EUR TOPTAN YUVARLAMA", "EUR PERAKENDE MODU", "EUR PERAKENDE DEGERI", "EUR TABAN", "EUR 1", "EUR 2", "EUR 3", "EUR 4", "EUR 5", "EUR 6", } var b strings.Builder for i, h := range headers { @@ -551,10 +773,15 @@ func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string { row.UrunAnaGrubu, row.UrunAltGrubu, row.Icerik, - row.Marka, + csvExcelTextValue(row.Marka), csvExcelTextValue(row.BrandCode), row.BrandGroupSec, + pricingRuleStringValue(row, "anchor_mode"), + map[bool]string{true: "Aktif", false: "Pasif"}[row.Rule == nil || row.Rule.CalcEnabled], + map[bool]string{true: "Evet", false: "Hayir"}[row.Rule == nil || row.Rule.PublishPostgres], + map[bool]string{true: "Evet", false: "Hayir"}[row.Rule == nil || row.Rule.PublishNebim], fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_wholesale_step")), + pricingRuleStringValue(row, "try_retail_mode"), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_retail_step")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_base")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try1")), @@ -564,6 +791,7 @@ func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string { fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try5")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try6")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_wholesale_step")), + pricingRuleStringValue(row, "usd_retail_mode"), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_retail_step")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_base")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd1")), @@ -573,6 +801,7 @@ func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string { fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd5")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd6")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_wholesale_step")), + pricingRuleStringValue(row, "eur_retail_mode"), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_retail_step")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_base")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur1")), diff --git a/svc/routes/product_pricing_calc.go b/svc/routes/product_pricing_calc.go new file mode 100644 index 0000000..38af34f --- /dev/null +++ b/svc/routes/product_pricing_calc.go @@ -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) + } +} diff --git a/svc/routes/product_pricing_change_mail.go b/svc/routes/product_pricing_change_mail.go new file mode 100644 index 0000000..c3f4da0 --- /dev/null +++ b/svc/routes/product_pricing_change_mail.go @@ -0,0 +1,265 @@ +package routes + +import ( + "context" + "database/sql" + "fmt" + "log" + "sort" + "strings" + "time" + + "bssapp-backend/db" + "bssapp-backend/internal/mailer" + "bssapp-backend/models" + "bssapp-backend/queries" +) + +func loadPricingRecipients(pg *sql.DB, firstGroupCode string) ([]string, error) { + rows, err := pg.Query(` +SELECT DISTINCT TRIM(m.email) AS email +FROM mk_pricing_first_group_mail f +JOIN mk_mail m + ON m.id = f.mail_id +WHERE m.is_active = true + AND COALESCE(TRIM(m.email), '') <> '' + AND UPPER(TRIM(f.urun_ilk_grubu)) = UPPER(TRIM($1)) +ORDER BY email +`, strings.TrimSpace(firstGroupCode)) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]string, 0, 16) + for rows.Next() { + var email string + if err := rows.Scan(&email); err != nil { + return nil, err + } + email = strings.TrimSpace(email) + if email != "" { + out = append(out, email) + } + } + return out, rows.Err() +} + +func htmlEscapeMini(s string) string { + // Minimal safe escaping for our templated cells. + r := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + "\"", """, + "'", "'", + ) + return r.Replace(s) +} + +func fmtMoneyMail(v float64) string { return fmt.Sprintf("%.2f", v) } +func fmtQtyMail(v float64) string { return fmt.Sprintf("%.2f", v) } + +func fmtDateTRFromISO(d string) string { + d = strings.TrimSpace(d) + if len(d) >= 10 { + d = d[:10] + } + parts := strings.Split(d, "-") + if len(parts) != 3 { + if d == "" { + return "-" + } + return d + } + y, m, day := parts[0], parts[1], parts[2] + if y == "" || m == "" || day == "" { + return d + } + return day + "." + m + "." + y +} + +func buildPricingChangeMailHTML(firstGroupCode string, rows []models.ProductPricing, actor string, at time.Time) string { + // Keep it simple: wide, scrollable table. + var b strings.Builder + // NOTE: Mail clients often render small fonts; keep this comfortably readable. + // Use large inline sizes (some clients still downscale); keep everything inline for maximum compatibility. + b.WriteString(`
| ` + htmlEscapeMini(h) + ` | `) + } + b.WriteString(`
|---|
| ` + htmlEscapeMini(strings.TrimSpace(c)) + ` | `) + } + b.WriteString(`