From e14c1c176aabadad0088791eaa770fd860f82e92 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Wed, 17 Jun 2026 21:56:49 +0300 Subject: [PATCH] Merge remote-tracking branch 'origin/master' --- logs/backend-20260617-171953.err.log | 1 + logs/backend-20260617-171953.out.log | 202 +++ logs/ui-dev-20260617-171953.err.log | 40 + logs/ui-dev-20260617-171953.out.log | 144 ++ svc/main.go | 50 + svc/product_pricing_fx_delta_scheduler.go | 123 ++ svc/product_pricing_fx_full_scheduler.go | 148 ++ svc/queries/brand_classification.go | 50 +- svc/queries/pricing_calc_engine.go | 679 ++++++++ svc/queries/pricing_calc_infra.go | 188 ++ svc/queries/pricing_parameters.go | 49 +- svc/queries/pricing_rules.go | 452 ++++- svc/queries/product_pricing.go | 16 +- svc/queries/product_pricing_dims_mssql.go | 25 + svc/queries/product_pricing_fx_publish.go | 362 ++++ svc/queries/product_pricing_recalc_queue.go | 174 ++ svc/routes/brand_group_currency.go | 97 ++ svc/routes/pricing_rules.go | 251 ++- svc/routes/product_pricing_calc.go | 107 ++ svc/routes/product_pricing_change_mail.go | 265 +++ svc/routes/product_pricing_history.go | 505 ++++++ .../product_pricing_price_list_export.go | 492 ++++++ svc/routes/product_pricing_save.go | 1195 +++++++++++++ ui/.quasar/prod-spa/app.js | 75 - ui/.quasar/prod-spa/client-entry.js | 158 -- ui/.quasar/prod-spa/client-prefetch.js | 116 -- ui/.quasar/prod-spa/quasar-user-options.js | 23 - ...g.js.temporary.compiled.1781721394097.mjs} | 0 ui/src/pages/BrandGroupCurrency.vue | 158 ++ ui/src/pages/PricingRules.vue | 412 ++++- ui/src/pages/ProductPricing.vue | 1525 ++++++++++++++--- ui/src/router/routes.js | 7 + ui/src/services/api.js | 3 +- ui/src/stores/ProductPricingStore.js | 14 +- 34 files changed, 7402 insertions(+), 704 deletions(-) create mode 100644 logs/backend-20260617-171953.err.log create mode 100644 logs/backend-20260617-171953.out.log create mode 100644 logs/ui-dev-20260617-171953.err.log create mode 100644 logs/ui-dev-20260617-171953.out.log create mode 100644 svc/product_pricing_fx_delta_scheduler.go create mode 100644 svc/product_pricing_fx_full_scheduler.go create mode 100644 svc/queries/pricing_calc_engine.go create mode 100644 svc/queries/pricing_calc_infra.go create mode 100644 svc/queries/product_pricing_dims_mssql.go create mode 100644 svc/queries/product_pricing_fx_publish.go create mode 100644 svc/queries/product_pricing_recalc_queue.go create mode 100644 svc/routes/brand_group_currency.go create mode 100644 svc/routes/product_pricing_calc.go create mode 100644 svc/routes/product_pricing_change_mail.go create mode 100644 svc/routes/product_pricing_history.go create mode 100644 svc/routes/product_pricing_price_list_export.go create mode 100644 svc/routes/product_pricing_save.go delete mode 100644 ui/.quasar/prod-spa/app.js delete mode 100644 ui/.quasar/prod-spa/client-entry.js delete mode 100644 ui/.quasar/prod-spa/client-prefetch.js delete mode 100644 ui/.quasar/prod-spa/quasar-user-options.js rename ui/{quasar.config.js.temporary.compiled.1780488176351.mjs => quasar.config.js.temporary.compiled.1781721394097.mjs} (100%) create mode 100644 ui/src/pages/BrandGroupCurrency.vue 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(`
`) + b.WriteString(`
`) + b.WriteString(`
Fiyat Degisikligi
`) + b.WriteString(`
Urun Ilk Grubu: ` + htmlEscapeMini(firstGroupCode) + `
`) + if strings.TrimSpace(actor) != "" { + b.WriteString(`
Islem Yapan: ` + htmlEscapeMini(actor) + `
`) + } + b.WriteString(`
Tarih: ` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `
`) + b.WriteString(`
Urun Sayisi: ` + fmt.Sprintf("%d", len(rows)) + `
`) + b.WriteString(`
`) + + b.WriteString(`
`) + b.WriteString(``) + b.WriteString(``) + + heads := []string{ + "MARKA GRUBU", "MARKA", "BRAND CODE", "URUN KODU", + "STOK ADET", "STOK GIRIS", "SON MALIYET", "SON FIYAT", + "ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "KARISIM", + "MALIYET FIYATI", "TABAN USD", "TABAN TRY", + "USD1", "USD2", "USD3", "USD4", "USD5", "USD6", + "EUR1", "EUR2", "EUR3", "EUR4", "EUR5", "EUR6", + "TRY1", "TRY2", "TRY3", "TRY4", "TRY5", "TRY6", + } + for _, h := range heads { + b.WriteString(``) + } + b.WriteString(``) + + for _, r := range rows { + b.WriteString(``) + cells := []string{ + r.BrandGroupSec, + r.Marka, + r.BrandCode, + r.ProductCode, + fmtQtyMail(r.StockQty), + fmtDateTRFromISO(r.StockEntryDate), + fmtDateTRFromISO(r.LastCostingDate), + fmtDateTRFromISO(r.LastPricingDate), + r.AskiliYan, + r.Kategori, + r.UrunIlkGrubu, + r.UrunAnaGrubu, + r.UrunAltGrubu, + r.Icerik, + r.Karisim, + fmtMoneyMail(r.CostPrice), + fmtMoneyMail(r.BasePriceUsd), + fmtMoneyMail(r.BasePriceTry), + fmtMoneyMail(r.USD1), fmtMoneyMail(r.USD2), fmtMoneyMail(r.USD3), fmtMoneyMail(r.USD4), fmtMoneyMail(r.USD5), fmtMoneyMail(r.USD6), + fmtMoneyMail(r.EUR1), fmtMoneyMail(r.EUR2), fmtMoneyMail(r.EUR3), fmtMoneyMail(r.EUR4), fmtMoneyMail(r.EUR5), fmtMoneyMail(r.EUR6), + fmtMoneyMail(r.TRY1), fmtMoneyMail(r.TRY2), fmtMoneyMail(r.TRY3), fmtMoneyMail(r.TRY4), fmtMoneyMail(r.TRY5), fmtMoneyMail(r.TRY6), + } + for i, c := range cells { + align := "left" + // right align numeric-ish cells + if i >= 4 { + switch i { + case 4, 15, 16, 17, + 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, + 30, 31, 32, 33, 34, 35: + align = "right" + } + } + b.WriteString(``) + } + b.WriteString(``) + } + + b.WriteString(`
` + htmlEscapeMini(h) + `
` + htmlEscapeMini(strings.TrimSpace(c)) + `
`) + b.WriteString(`
Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.
`) + b.WriteString(`
`) + return b.String() +} + +// sendPricingChangeMails sends one mail per UrunIlkGrubu (group) based on mk_pricing_first_group_mail mapping. +// It is designed to be called post-commit in a goroutine. +func sendPricingChangeMails(bg context.Context, ml *mailer.GraphMailer, productCodes []string, actor string) { + if ml == nil { + return + } + pg := db.PgDB + if pg == nil { + log.Printf("[pricing-mail] skipped: pg not ready") + return + } + // Ensure mapping tables exist. + if err := ensureFirstGroupMailMappingTables(pg); err != nil { + log.Printf("[pricing-mail] mapping bootstrap error: %v", err) + return + } + + ctx, cancel := context.WithTimeout(bg, 90*time.Second) + defer cancel() + + codes := make([]string, 0, len(productCodes)) + seen := map[string]struct{}{} + for _, c := range productCodes { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + codes = append(codes, c) + } + if len(codes) == 0 { + return + } + + rows, err := queries.GetAllProductPricingRows(ctx, 500, queries.ProductPricingFilters{ProductCode: codes}, "productCode", false) + if err != nil { + log.Printf("[pricing-mail] pricing rows query error: %v", err) + return + } + if len(rows) == 0 { + return + } + + byGroup := map[string][]models.ProductPricing{} + for _, r := range rows { + g := strings.TrimSpace(r.UrunIlkGrubu) + if g == "" { + g = "UNKNOWN" + } + byGroup[g] = append(byGroup[g], r) + } + + now := time.Now() + for group, list := range byGroup { + // No mapping = skip. + recipients, err := loadPricingRecipients(pg, group) + if err != nil { + log.Printf("[pricing-mail] recipient query error group=%s err=%v", group, err) + continue + } + if len(recipients) == 0 { + log.Printf("[pricing-mail] no recipients mapped group=%s", group) + continue + } + + sort.Slice(list, func(i, j int) bool { + return strings.TrimSpace(list[i].ProductCode) < strings.TrimSpace(list[j].ProductCode) + }) + + subject := fmt.Sprintf("Fiyat Degisikligi | %s | %s | %d urun", group, now.Format("02.01.2006 15:04"), len(list)) + html := buildPricingChangeMailHTML(group, list, actor, now) + + // Retry 2 times with backoff. + backoff := []time.Duration{800 * time.Millisecond, 2500 * time.Millisecond} + var lastErr error + for attempt := 0; attempt < len(backoff)+1; attempt++ { + if attempt > 0 { + time.Sleep(backoff[attempt-1]) + } + stepCtx, stepCancel := context.WithTimeout(bg, 25*time.Second) + err := ml.Send(stepCtx, mailer.Message{ + To: recipients, + Subject: subject, + BodyHTML: html, + }) + stepCancel() + if err == nil { + lastErr = nil + break + } + lastErr = err + } + if lastErr != nil { + log.Printf("[pricing-mail] send failed group=%s err=%v", group, lastErr) + } else { + log.Printf("[pricing-mail] sent group=%s to=%d products=%d", group, len(recipients), len(list)) + } + } +} diff --git a/svc/routes/product_pricing_history.go b/svc/routes/product_pricing_history.go new file mode 100644 index 0000000..507c413 --- /dev/null +++ b/svc/routes/product_pricing_history.go @@ -0,0 +1,505 @@ +package routes + +import ( + "bssapp-backend/auth" + "bssapp-backend/db" + "bssapp-backend/queries" + "bssapp-backend/utils" + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gorilla/mux" + "github.com/lib/pq" +) + +type productPricingHistoryPGRow struct { + ID string `json:"id"` + Currency string `json:"currency"` + LevelNo int `json:"level_no"` + Price float64 `json:"price"` + UpdatedAt string `json:"updated_at"` + SdprcGrpID int `json:"sdprcgrp_id"` +} + +type productPricingHistoryMSSQLRow struct { + PriceListLineID string `json:"price_list_line_id"` + Currency string `json:"currency"` + PriceGroupCode string `json:"price_group_code"` + Price float64 `json:"price"` + ValidDate string `json:"valid_date"` + ValidTime string `json:"valid_time"` + LastUpdatedDate string `json:"last_updated_date"` + IsDisabled bool `json:"is_disabled"` +} + +type productPricingHistoryResponse struct { + ProductCode string `json:"product_code"` + Postgres []productPricingHistoryPGRow `json:"postgres"` + Mssql []productPricingHistoryMSSQLRow `json:"mssql"` +} + +func GetProductPricingHistoryHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + w.Header().Set("X-Trace-ID", traceID) + + claims, ok := auth.GetClaimsFromContext(r.Context()) + if !ok || claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + productCode := strings.TrimSpace(mux.Vars(r)["code"]) + if productCode == "" { + http.Error(w, "product code required", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) + defer cancel() + + // Load nebim price groups from PG mapping (18) + base groups (2). + priceGroups := []string{"TM-USD", "TM-TRY"} + if pg != nil { + rows, err := pg.QueryContext(ctx, ` +SELECT DISTINCT COALESCE(NULLIF(BTRIM(price_group_code), ''), '') +FROM mk_price_target_map_nebim +WHERE is_active = TRUE +`) + if err == nil { + for rows.Next() { + var code string + if err := rows.Scan(&code); err != nil { + _ = rows.Close() + break + } + code = strings.TrimSpace(code) + if code != "" { + priceGroups = append(priceGroups, code) + } + } + _ = rows.Close() + } + } + + resp := productPricingHistoryResponse{ + ProductCode: productCode, + Postgres: []productPricingHistoryPGRow{}, + Mssql: []productPricingHistoryMSSQLRow{}, + } + + // Postgres sdprc history. + if pg != nil { + pgRows, err := pg.QueryContext(ctx, ` +SELECT + sdprc.id::text, + sdprc.crn, + sdprc.sdprcgrp_id, + COALESCE(sdprc.prc, 0)::float8, + TO_CHAR(sdprc.zlins_dttm, 'YYYY-MM-DD HH24:MI:SS') +FROM sdprc +JOIN mmitem ON mmitem.id = sdprc.mmitem_id +WHERE mmitem.code = $1 + AND sdprc.crn IN ('USD','EUR','TRY') + AND sdprc.sdprcgrp_id BETWEEN 1 AND 6 +ORDER BY sdprc.zlins_dttm DESC +LIMIT 400; +`, productCode) + if err == nil { + for pgRows.Next() { + var id, cur, at string + var grp int + var prc float64 + if err := pgRows.Scan(&id, &cur, &grp, &prc, &at); err != nil { + _ = pgRows.Close() + http.Error(w, "pg history scan error", http.StatusInternalServerError) + return + } + resp.Postgres = append(resp.Postgres, productPricingHistoryPGRow{ + ID: strings.TrimSpace(id), + Currency: strings.ToUpper(strings.TrimSpace(cur)), + SdprcGrpID: grp, + LevelNo: grp, + Price: prc, + UpdatedAt: strings.TrimSpace(at), + }) + } + _ = pgRows.Close() + } + } + + // MSSQL trPriceListLine history (only relevant price groups). + mssql := db.GetDB() + if mssql != nil { + // Build a safe "IN" via OR parameters. + conds := make([]string, 0, len(priceGroups)) + args := make([]any, 0, len(priceGroups)+1) + args = append(args, sql.Named("p1", productCode)) + for i, g := range priceGroups { + name := fmt.Sprintf("g%d", i+1) + conds = append(conds, "LTRIM(RTRIM(p.PriceGroupCode)) = @"+name) + args = append(args, sql.Named(name, g)) + } + wherePG := "1=0" + if len(conds) > 0 { + wherePG = "(" + strings.Join(conds, " OR ") + ")" + } + q := ` +SELECT TOP (400) + CONVERT(NVARCHAR(36), p.PriceListLineID) AS PriceListLineID, + LTRIM(RTRIM(p.DocCurrencyCode)) AS DocCurrencyCode, + LTRIM(RTRIM(p.PriceGroupCode)) AS PriceGroupCode, + CAST(p.Price AS FLOAT) AS Price, + CONVERT(VARCHAR(10), p.ValidDate, 23) AS ValidDate, + CONVERT(VARCHAR(8), p.ValidTime, 108) AS ValidTime, + CONVERT(VARCHAR(19), p.LastUpdatedDate, 120) AS LastUpdatedDate, + CAST(ISNULL(p.IsDisabled, 0) AS BIT) AS IsDisabled +FROM dbo.trPriceListLine p WITH(NOLOCK) +WHERE p.ItemTypeCode = 1 + AND LTRIM(RTRIM(p.ItemCode)) = @p1 + AND ` + wherePG + ` +ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC; +` + rows, err := mssql.QueryContext(ctx, q, args...) + if err == nil { + for rows.Next() { + var id, cur, grp, vd, vt, lud string + var prc float64 + var disabled bool + if err := rows.Scan(&id, &cur, &grp, &prc, &vd, &vt, &lud, &disabled); err != nil { + _ = rows.Close() + http.Error(w, "mssql history scan error", http.StatusInternalServerError) + return + } + resp.Mssql = append(resp.Mssql, productPricingHistoryMSSQLRow{ + PriceListLineID: strings.TrimSpace(id), + Currency: strings.ToUpper(strings.TrimSpace(cur)), + PriceGroupCode: strings.TrimSpace(grp), + Price: prc, + ValidDate: strings.TrimSpace(vd), + ValidTime: strings.TrimSpace(vt), + LastUpdatedDate: strings.TrimSpace(lud), + IsDisabled: disabled, + }) + } + _ = rows.Close() + } + } + + _ = json.NewEncoder(w).Encode(resp) + } +} + +type deleteLatestPriceHistoryRequest struct { + DeletePostgres bool `json:"delete_postgres"` + DeleteMssql bool `json:"delete_mssql"` + Currency string `json:"currency"` // USD/EUR/TRY + LevelNo int `json:"level_no"` // 1..6 (tier); for base use 0 + IsBase bool `json:"is_base"` + PriceGroupCode string `json:"price_group_code"` // optional override for MSSQL deletes +} + +func PostDeleteLatestProductPriceHistoryHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + w.Header().Set("X-Trace-ID", traceID) + + claims, ok := auth.GetClaimsFromContext(r.Context()) + if !ok || claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + productCode := strings.TrimSpace(mux.Vars(r)["code"]) + if productCode == "" { + http.Error(w, "product code required", http.StatusBadRequest) + return + } + + var req deleteLatestPriceHistoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + if !req.DeletePostgres && !req.DeleteMssql { + req.DeletePostgres = true + req.DeleteMssql = true + } + + cur := strings.ToUpper(strings.TrimSpace(req.Currency)) + if cur != "USD" && cur != "EUR" && cur != "TRY" { + http.Error(w, "invalid currency", http.StatusBadRequest) + return + } + if !req.IsBase && req.DeletePostgres && (req.LevelNo < 1 || req.LevelNo > 6) { + http.Error(w, "invalid level_no", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) + defer cancel() + + // PG delete (sdprc). + deletedPG := int64(0) + if req.DeletePostgres && !req.IsBase && pg != nil { + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "pg tx error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + var mmItemID int64 + if err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, productCode).Scan(&mmItemID); err != nil { + http.Error(w, "pg product not found", http.StatusNotFound) + return + } + grp := req.LevelNo + // Delete latest row for that currency+level. + res, err := tx.ExecContext(ctx, ` +DELETE FROM sdprc +WHERE id = ( + SELECT id + FROM sdprc + WHERE mmitem_id=$1 AND crn=$2 AND sdprcgrp_id=$3 + ORDER BY zlins_dttm DESC + LIMIT 1 +); +`, mmItemID, cur, grp) + if err != nil { + http.Error(w, "pg delete error", http.StatusInternalServerError) + return + } + deletedPG, _ = res.RowsAffected() + + // enqueue delta recompute for this product to keep derived currencies consistent + _, _ = queries.EnqueuePriceRecalc(ctx, tx, []string{productCode}, "history_delete") + + if err := tx.Commit(); err != nil { + http.Error(w, "pg commit error", http.StatusInternalServerError) + return + } + } + + // MSSQL delete (trPriceListLine). + deletedMSSQL := int64(0) + if req.DeleteMssql { + mssql := db.GetDB() + if mssql == nil { + http.Error(w, "mssql not connected", http.StatusInternalServerError) + return + } + tx, err := mssql.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "mssql tx error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + priceGroup := strings.TrimSpace(req.PriceGroupCode) + if req.IsBase { + if cur == "USD" { + priceGroup = "TM-USD" + } else if cur == "TRY" { + priceGroup = "TM-TRY" + } else { + http.Error(w, "base only supports USD/TRY", http.StatusBadRequest) + return + } + } else if priceGroup == "" && pg != nil { + _ = pg.QueryRowContext(ctx, ` +SELECT COALESCE(NULLIF(BTRIM(price_group_code), ''), '') +FROM mk_price_target_map_nebim +WHERE is_active=TRUE AND currency=$1 AND level_no=$2 +`, cur, req.LevelNo).Scan(&priceGroup) + } + priceGroup = strings.TrimSpace(priceGroup) + if priceGroup == "" { + http.Error(w, "missing price group mapping", http.StatusBadRequest) + return + } + + res, err := tx.ExecContext(ctx, ` +;WITH latest AS ( + SELECT TOP (1) p.PriceListLineID + FROM dbo.trPriceListLine p WITH(UPDLOCK, ROWLOCK) + WHERE p.ItemTypeCode=1 + AND LTRIM(RTRIM(p.ItemCode))=@p1 + AND LTRIM(RTRIM(p.DocCurrencyCode))=@p2 + AND LTRIM(RTRIM(p.PriceGroupCode))=@p3 + AND ISNULL(p.IsDisabled, 0)=0 + ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC +) +DELETE FROM dbo.trPriceListLine +WHERE PriceListLineID IN (SELECT PriceListLineID FROM latest); +`, sql.Named("p1", productCode), sql.Named("p2", cur), sql.Named("p3", priceGroup)) + if err != nil { + http.Error(w, "mssql delete error", http.StatusInternalServerError) + return + } + deletedMSSQL, _ = res.RowsAffected() + + if err := tx.Commit(); err != nil { + http.Error(w, "mssql commit error", http.StatusInternalServerError) + return + } + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "product_code": productCode, + "deleted_pg": deletedPG, + "deleted_mssql": deletedMSSQL, + "actor_user": claims.Username, + "actor_user_id": claims.ID, + }) + } +} + +type deleteSelectedPriceHistoryRequest struct { + PGIDs []string `json:"pg_ids"` // sdprc.id (uuid) + MSSQLIDs []string `json:"mssql_ids"` // trPriceListLine.PriceListLineID (uuid) +} + +func PostDeleteSelectedProductPriceHistoryHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + w.Header().Set("X-Trace-ID", traceID) + + claims, ok := auth.GetClaimsFromContext(r.Context()) + if !ok || claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + productCode := strings.TrimSpace(mux.Vars(r)["code"]) + if productCode == "" { + http.Error(w, "product code required", http.StatusBadRequest) + return + } + + var req deleteSelectedPriceHistoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + // normalize ids + pgIDs := make([]string, 0, len(req.PGIDs)) + for _, x := range req.PGIDs { + s := strings.TrimSpace(x) + if s != "" { + pgIDs = append(pgIDs, s) + } + } + msIDs := make([]string, 0, len(req.MSSQLIDs)) + for _, x := range req.MSSQLIDs { + s := strings.TrimSpace(x) + if s != "" { + msIDs = append(msIDs, s) + } + } + if len(pgIDs) == 0 && len(msIDs) == 0 { + http.Error(w, "no ids selected", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) + defer cancel() + + deletedPG := int64(0) + if len(pgIDs) > 0 && pg != nil { + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "pg tx error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Resolve product id to constrain deletes to the given productCode. + var mmItemID int64 + if err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, productCode).Scan(&mmItemID); err != nil { + http.Error(w, "pg product not found", http.StatusNotFound) + return + } + + // Delete only rows matching mmitem_id + id list. + res, err := tx.ExecContext(ctx, ` +DELETE FROM sdprc +WHERE mmitem_id = $1 + AND id = ANY($2::uuid[]); +`, mmItemID, pq.Array(pgIDs)) + if err != nil { + http.Error(w, "pg delete error", http.StatusInternalServerError) + return + } + deletedPG, _ = res.RowsAffected() + + _, _ = queries.EnqueuePriceRecalc(ctx, tx, []string{productCode}, "history_delete_selected") + + if err := tx.Commit(); err != nil { + http.Error(w, "pg commit error", http.StatusInternalServerError) + return + } + } + + deletedMSSQL := int64(0) + if len(msIDs) > 0 { + mssql := db.GetDB() + if mssql == nil { + http.Error(w, "mssql not connected", http.StatusInternalServerError) + return + } + tx, err := mssql.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "mssql tx error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Build a safe IN-list via named parameters. + placeholders := make([]string, 0, len(msIDs)) + args := make([]any, 0, len(msIDs)+1) + args = append(args, sql.Named("p1", productCode)) + for i, id := range msIDs { + name := fmt.Sprintf("id%d", i+1) + placeholders = append(placeholders, "@"+name) + args = append(args, sql.Named(name, id)) + } + + q := ` +DELETE FROM dbo.trPriceListLine +WHERE ItemTypeCode = 1 + AND LTRIM(RTRIM(ItemCode)) = @p1 + AND PriceListLineID IN (` + strings.Join(placeholders, ",") + `); +` + res, err := tx.ExecContext(ctx, q, args...) + if err != nil { + http.Error(w, "mssql delete error", http.StatusInternalServerError) + return + } + deletedMSSQL, _ = res.RowsAffected() + + if err := tx.Commit(); err != nil { + http.Error(w, "mssql commit error", http.StatusInternalServerError) + return + } + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "product_code": productCode, + "deleted_pg": deletedPG, + "deleted_mssql": deletedMSSQL, + "actor_user": claims.Username, + "actor_user_id": claims.ID, + }) + } +} diff --git a/svc/routes/product_pricing_price_list_export.go b/svc/routes/product_pricing_price_list_export.go new file mode 100644 index 0000000..5086ca7 --- /dev/null +++ b/svc/routes/product_pricing_price_list_export.go @@ -0,0 +1,492 @@ +package routes + +import ( + "bssapp-backend/auth" + "bssapp-backend/models" + "bssapp-backend/queries" + "bssapp-backend/utils" + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "math" + "net/http" + "sort" + "strings" + "time" + + "github.com/jung-kurt/gofpdf" + "github.com/xuri/excelize/v2" +) + +type priceListExportRequest struct { + // Product filters (same semantics as listing) + ProductCode []string `json:"product_code"` + BrandGroup []string `json:"brand_group"` + AskiliYan []string `json:"askili_yan"` + Kategori []string `json:"kategori"` + UrunIlkGrubu []string `json:"urun_ilk_grubu"` + UrunAnaGrubu []string `json:"urun_ana_grubu"` + UrunAltGrubu []string `json:"urun_alt_grubu"` + Icerik []string `json:"icerik"` + Karisim []string `json:"karisim"` + Marka []string `json:"marka"` + Search string `json:"search"` + + InStockOnly bool `json:"in_stock_only"` + + // Column selection + IncludeMeta bool `json:"include_meta"` + IncludeCost bool `json:"include_cost"` + IncludeBase bool `json:"include_base"` + + USDLevels []int `json:"usd_levels"` // 1..6 + EURLevels []int `json:"eur_levels"` // 1..6 + TRYLevels []int `json:"try_levels"` // 1..6 +} + +type exportCol struct { + Key string + Title string + Width float64 + Align string // L/R/C for PDF +} + +func cleanLevels(in []int) []int { + out := make([]int, 0, len(in)) + seen := map[int]struct{}{} + for _, v := range in { + if v < 1 || v > 6 { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Ints(out) + return out +} + +func resolvePriceListColumns(req priceListExportRequest) []exportCol { + cols := make([]exportCol, 0, 64) + + if req.IncludeMeta { + cols = append(cols, + exportCol{Key: "BrandGroupSec", Title: "MARKA GRUBU", Width: 26, Align: "L"}, + exportCol{Key: "Marka", Title: "MARKA", Width: 18, Align: "L"}, + exportCol{Key: "BrandCode", Title: "BRAND CODE", Width: 18, Align: "L"}, + exportCol{Key: "ProductCode", Title: "URUN KODU", Width: 22, Align: "L"}, + exportCol{Key: "StockQty", Title: "STOK ADET", Width: 16, Align: "R"}, + exportCol{Key: "StockEntryDate", Title: "STOK GIRIS", Width: 18, Align: "C"}, + exportCol{Key: "LastCostingDate", Title: "SON MALIYET", Width: 18, Align: "C"}, + exportCol{Key: "LastPricingDate", Title: "SON FIYAT", Width: 18, Align: "C"}, + exportCol{Key: "AskiliYan", Title: "ASKILI YAN", Width: 18, Align: "L"}, + exportCol{Key: "Kategori", Title: "KATEGORI", Width: 18, Align: "L"}, + exportCol{Key: "UrunIlkGrubu", Title: "URUN ILK GRUBU", Width: 20, Align: "L"}, + exportCol{Key: "UrunAnaGrubu", Title: "URUN ANA GRUBU", Width: 20, Align: "L"}, + exportCol{Key: "UrunAltGrubu", Title: "URUN ALT GRUBU", Width: 20, Align: "L"}, + exportCol{Key: "Icerik", Title: "ICERIK", Width: 18, Align: "L"}, + exportCol{Key: "Karisim", Title: "KARISIM", Width: 18, Align: "L"}, + ) + } + if req.IncludeCost { + cols = append(cols, exportCol{Key: "CostPrice", Title: "MALIYET FIYATI", Width: 16, Align: "R"}) + } + if req.IncludeBase { + cols = append(cols, + exportCol{Key: "BasePriceUsd", Title: "TABAN USD", Width: 14, Align: "R"}, + exportCol{Key: "BasePriceTry", Title: "TABAN TRY", Width: 14, Align: "R"}, + ) + } + + usd := cleanLevels(req.USDLevels) + eur := cleanLevels(req.EURLevels) + tr := cleanLevels(req.TRYLevels) + for _, lv := range usd { + cols = append(cols, exportCol{Key: fmt.Sprintf("USD%d", lv), Title: fmt.Sprintf("USD %d", lv), Width: 12, Align: "R"}) + } + for _, lv := range eur { + cols = append(cols, exportCol{Key: fmt.Sprintf("EUR%d", lv), Title: fmt.Sprintf("EUR %d", lv), Width: 12, Align: "R"}) + } + for _, lv := range tr { + cols = append(cols, exportCol{Key: fmt.Sprintf("TRY%d", lv), Title: fmt.Sprintf("TRY %d", lv), Width: 12, Align: "R"}) + } + + return cols +} + +func fmtMoneyCell(v float64) string { + if math.IsNaN(v) || math.IsInf(v, 0) { + return "" + } + return fmt.Sprintf("%.2f", v) +} + +func getCellValue(row models.ProductPricing, key string) string { + switch key { + case "BrandGroupSec": + return strings.TrimSpace(row.BrandGroupSec) + case "Marka": + return strings.TrimSpace(row.Marka) + case "BrandCode": + return strings.TrimSpace(row.BrandCode) + case "ProductCode": + return strings.TrimSpace(row.ProductCode) + case "StockQty": + return fmtMoneyCell(row.StockQty) + case "StockEntryDate": + return strings.TrimSpace(row.StockEntryDate) + case "LastCostingDate": + return strings.TrimSpace(row.LastCostingDate) + case "LastPricingDate": + return strings.TrimSpace(row.LastPricingDate) + case "AskiliYan": + return strings.TrimSpace(row.AskiliYan) + case "Kategori": + return strings.TrimSpace(row.Kategori) + case "UrunIlkGrubu": + return strings.TrimSpace(row.UrunIlkGrubu) + case "UrunAnaGrubu": + return strings.TrimSpace(row.UrunAnaGrubu) + case "UrunAltGrubu": + return strings.TrimSpace(row.UrunAltGrubu) + case "Icerik": + return strings.TrimSpace(row.Icerik) + case "Karisim": + return strings.TrimSpace(row.Karisim) + case "CostPrice": + return fmtMoneyCell(row.CostPrice) + case "BasePriceUsd": + return fmtMoneyCell(row.BasePriceUsd) + case "BasePriceTry": + return fmtMoneyCell(row.BasePriceTry) + case "USD1": + return fmtMoneyCell(row.USD1) + case "USD2": + return fmtMoneyCell(row.USD2) + case "USD3": + return fmtMoneyCell(row.USD3) + case "USD4": + return fmtMoneyCell(row.USD4) + case "USD5": + return fmtMoneyCell(row.USD5) + case "USD6": + return fmtMoneyCell(row.USD6) + case "EUR1": + return fmtMoneyCell(row.EUR1) + case "EUR2": + return fmtMoneyCell(row.EUR2) + case "EUR3": + return fmtMoneyCell(row.EUR3) + case "EUR4": + return fmtMoneyCell(row.EUR4) + case "EUR5": + return fmtMoneyCell(row.EUR5) + case "EUR6": + return fmtMoneyCell(row.EUR6) + case "TRY1": + return fmtMoneyCell(row.TRY1) + case "TRY2": + return fmtMoneyCell(row.TRY2) + case "TRY3": + return fmtMoneyCell(row.TRY3) + case "TRY4": + return fmtMoneyCell(row.TRY4) + case "TRY5": + return fmtMoneyCell(row.TRY5) + case "TRY6": + return fmtMoneyCell(row.TRY6) + default: + return "" + } +} + +func ExportProductPriceListExcelHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + traceID := utils.TraceIDFromRequest(r) + claims, ok := auth.GetClaimsFromContext(r.Context()) + if !ok || claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req priceListExportRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + if !req.IncludeMeta && !req.IncludeCost && !req.IncludeBase && len(req.USDLevels) == 0 && len(req.EURLevels) == 0 && len(req.TRYLevels) == 0 { + req.IncludeMeta = true + req.IncludeCost = true + req.IncludeBase = true + req.USDLevels = []int{1, 2, 3, 4, 5, 6} + req.EURLevels = []int{1, 2, 3, 4, 5, 6} + req.TRYLevels = []int{1, 2, 3, 4, 5, 6} + } + if req.IncludeMeta == false { + req.IncludeMeta = true + } + + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 2*time.Minute) + defer cancel() + + filters := queries.ProductPricingFilters{ + Search: strings.TrimSpace(req.Search), + ProductCode: req.ProductCode, + BrandGroup: req.BrandGroup, + AskiliYan: req.AskiliYan, + Kategori: req.Kategori, + UrunIlkGrubu: req.UrunIlkGrubu, + UrunAnaGrubu: req.UrunAnaGrubu, + UrunAltGrubu: req.UrunAltGrubu, + Icerik: req.Icerik, + Karisim: req.Karisim, + Marka: req.Marka, + } + + rows, err := queries.GetAllProductPricingRows(ctx, 500, filters, "productCode", false) + if err != nil { + http.Error(w, "query error", http.StatusInternalServerError) + return + } + if req.InStockOnly { + tmp := make([]models.ProductPricing, 0, len(rows)) + for _, it := range rows { + if it.StockQty > 0.0001 { + tmp = append(tmp, it) + } + } + rows = tmp + } + + cols := resolvePriceListColumns(req) + + f := excelize.NewFile() + defer func() { _ = f.Close() }() + sheet := "Fiyat Listesi" + f.SetSheetName("Sheet1", sheet) + + now := time.Now() + title := "BAGGI - GUNCEL FIYAT LISTESI" + dateLine := "Tarih: " + now.Format("02.01.2006") + + _ = f.SetCellValue(sheet, "A1", title) + _ = f.SetCellValue(sheet, "A2", dateLine) + _ = f.MergeCell(sheet, "A1", "H1") + _ = f.MergeCell(sheet, "A2", "H2") + + // Try to add logo (best-effort). + if logoPath, err := resolvePdfImagePath("Baggi-Tekstil-A.s-Logolu.jpeg"); err == nil { + _ = f.AddPicture(sheet, "I1", logoPath, &excelize.GraphicOptions{ + ScaleX: 0.25, + ScaleY: 0.25, + }) + } + + // Header row + headerRow := 4 + for i, c := range cols { + cell, _ := excelize.CoordinatesToCellName(i+1, headerRow) + _ = f.SetCellValue(sheet, cell, c.Title) + colName, _ := excelize.ColumnNumberToName(i + 1) + _ = f.SetColWidth(sheet, colName, colName, c.Width) + } + // Freeze panes at header + _ = f.SetPanes(sheet, &excelize.Panes{ + Freeze: true, + Split: false, + XSplit: 0, + YSplit: headerRow, + TopLeftCell: "A5", + ActivePane: "bottomLeft", + }) + + // Basic styles + hStyle, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "#FFFFFF"}, + Fill: excelize.Fill{Type: "pattern", Color: []string{"#957116"}, Pattern: 1}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, + Border: []excelize.Border{ + {Type: "left", Color: "#C0C0C0", Style: 1}, + {Type: "top", Color: "#C0C0C0", Style: 1}, + {Type: "bottom", Color: "#C0C0C0", Style: 1}, + {Type: "right", Color: "#C0C0C0", Style: 1}, + }, + }) + lastHeaderCell, _ := excelize.CoordinatesToCellName(len(cols), headerRow) + _ = f.SetCellStyle(sheet, "A4", lastHeaderCell, hStyle) + + // Data rows + startRow := headerRow + 1 + for ri, row := range rows { + excelRow := startRow + ri + for ci, c := range cols { + cell, _ := excelize.CoordinatesToCellName(ci+1, excelRow) + _ = f.SetCellValue(sheet, cell, getCellValue(row, c.Key)) + } + } + + // Autofilter + _ = f.AutoFilter(sheet, fmt.Sprintf("A4:%s", lastHeaderCell), []excelize.AutoFilterOptions{}) + + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + http.Error(w, "excel write error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fmt.Sprintf("baggi_guncel_fiyat_listesi_%s.xlsx", now.Format("20060102")))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf.Bytes()) + } +} + +func ExportProductPriceListPDFHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + traceID := utils.TraceIDFromRequest(r) + claims, ok := auth.GetClaimsFromContext(r.Context()) + if !ok || claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var req priceListExportRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + if !req.IncludeMeta && !req.IncludeCost && !req.IncludeBase && len(req.USDLevels) == 0 && len(req.EURLevels) == 0 && len(req.TRYLevels) == 0 { + req.IncludeMeta = true + req.IncludeCost = true + req.IncludeBase = true + req.USDLevels = []int{1, 2, 3, 4, 5, 6} + req.EURLevels = []int{1, 2, 3, 4, 5, 6} + req.TRYLevels = []int{1, 2, 3, 4, 5, 6} + } + req.IncludeMeta = true + + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 2*time.Minute) + defer cancel() + + filters := queries.ProductPricingFilters{ + Search: strings.TrimSpace(req.Search), + ProductCode: req.ProductCode, + BrandGroup: req.BrandGroup, + AskiliYan: req.AskiliYan, + Kategori: req.Kategori, + UrunIlkGrubu: req.UrunIlkGrubu, + UrunAnaGrubu: req.UrunAnaGrubu, + UrunAltGrubu: req.UrunAltGrubu, + Icerik: req.Icerik, + Karisim: req.Karisim, + Marka: req.Marka, + } + rows, err := queries.GetAllProductPricingRows(ctx, 500, filters, "productCode", false) + if err != nil { + http.Error(w, "query error", http.StatusInternalServerError) + return + } + if req.InStockOnly { + tmp := make([]models.ProductPricing, 0, len(rows)) + for _, it := range rows { + if it.StockQty > 0.0001 { + tmp = append(tmp, it) + } + } + rows = tmp + } + + cols := resolvePriceListColumns(req) + + pdf := gofpdf.New("L", "mm", "A4", "") + pdf.SetMargins(8, 8, 8) + pdf.SetAutoPageBreak(true, 10) + _ = registerDejavuFonts(pdf, "dejavu") + pdf.AddPage() + + // Header: logo + title + date + y := 10.0 + if logoPath, err := resolvePdfImagePath("Baggi-Tekstil-A.s-Logolu.jpeg"); err == nil { + pdf.ImageOptions(logoPath, 8, y-2, 26, 0, false, gofpdf.ImageOptions{}, 0, "") + } + pdf.SetFont("dejavu", "B", 14) + pdf.SetTextColor(149, 113, 22) + pdf.SetXY(36, y) + pdf.CellFormat(0, 7, "BAGGI - GUNCEL FIYAT LISTESI", "", 0, "L", false, 0, "") + pdf.SetTextColor(0, 0, 0) + pdf.SetFont("dejavu", "", 9) + pdf.SetXY(36, y+7) + pdf.CellFormat(0, 5, "Tarih: "+time.Now().Format("02.01.2006"), "", 0, "L", false, 0, "") + pdf.SetXY(36, y+12) + pdf.CellFormat(0, 5, "Olusturan: "+strings.TrimSpace(claims.Username), "", 0, "L", false, 0, "") + + pdf.Ln(18) + + pageW, _ := pdf.GetPageSize() + availW := pageW - 16 + sumW := 0.0 + for _, c := range cols { + sumW += c.Width + } + scale := 1.0 + if sumW > 0 && sumW > availW { + scale = availW / sumW + } + + drawRow := func(isHeader bool, values []string) { + h := 6.0 + if isHeader { + pdf.SetFillColor(149, 113, 22) + pdf.SetTextColor(255, 255, 255) + pdf.SetFont("dejavu", "B", 7) + } else { + pdf.SetFillColor(255, 255, 255) + pdf.SetTextColor(0, 0, 0) + pdf.SetFont("dejavu", "", 7) + } + for i, c := range cols { + w := c.Width * scale + align := c.Align + if align == "" { + align = "L" + } + txt := "" + if i < len(values) { + txt = values[i] + } + pdf.CellFormat(w, h, txt, "1", 0, align, isHeader, 0, "") + } + pdf.Ln(-1) + } + + // Header row + headerVals := make([]string, 0, len(cols)) + for _, c := range cols { + headerVals = append(headerVals, c.Title) + } + drawRow(true, headerVals) + + for _, row := range rows { + vals := make([]string, 0, len(cols)) + for _, c := range cols { + vals = append(vals, getCellValue(row, c.Key)) + } + drawRow(false, vals) + } + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + http.Error(w, "pdf render error", http.StatusInternalServerError) + return + } + + now := time.Now() + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fmt.Sprintf("baggi_guncel_fiyat_listesi_%s.pdf", now.Format("20060102")))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf.Bytes()) + } +} diff --git a/svc/routes/product_pricing_save.go b/svc/routes/product_pricing_save.go new file mode 100644 index 0000000..426ba8a --- /dev/null +++ b/svc/routes/product_pricing_save.go @@ -0,0 +1,1195 @@ +package routes + +import ( + "bssapp-backend/auth" + "bssapp-backend/db" + "bssapp-backend/internal/mailer" + "bssapp-backend/queries" + "bssapp-backend/utils" + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "log/slog" + "math" + "net/http" + "strconv" + "strings" + "time" +) + +type productPricingSaveItem struct { + ProductCode string `json:"product_code"` + + BasePriceUsd float64 `json:"base_price_usd"` + BasePriceTry float64 `json:"base_price_try"` + + USD1 float64 `json:"usd1"` + USD2 float64 `json:"usd2"` + USD3 float64 `json:"usd3"` + USD4 float64 `json:"usd4"` + USD5 float64 `json:"usd5"` + USD6 float64 `json:"usd6"` + + EUR1 float64 `json:"eur1"` + EUR2 float64 `json:"eur2"` + EUR3 float64 `json:"eur3"` + EUR4 float64 `json:"eur4"` + EUR5 float64 `json:"eur5"` + EUR6 float64 `json:"eur6"` + + TRY1 float64 `json:"try1"` + TRY2 float64 `json:"try2"` + TRY3 float64 `json:"try3"` + TRY4 float64 `json:"try4"` + TRY5 float64 `json:"try5"` + TRY6 float64 `json:"try6"` +} + +type productPricingSavePayload struct { + Items []productPricingSaveItem `json:"items"` +} + +func resolveOrCreatePriceListHeaderID(ctx context.Context, tx *sql.Tx, priceGroup string, currency string, username string, logger *slog.Logger) (string, error) { + priceGroup = strings.TrimSpace(priceGroup) + currency = strings.ToUpper(strings.TrimSpace(currency)) + if priceGroup == "" { + return "", fmt.Errorf("empty price group") + } + if currency != "USD" && currency != "EUR" && currency != "TRY" { + return "", fmt.Errorf("invalid currency") + } + + // Try existing header for group+currency. + var headerID string + _ = tx.QueryRowContext(ctx, ` +SELECT TOP (1) CONVERT(NVARCHAR(36), PriceListHeaderID) +FROM dbo.trPriceListHeader WITH (UPDLOCK, HOLDLOCK) +WHERE CompanyCode = 1 + AND LTRIM(RTRIM(PriceGroupCode)) = @pg + AND LTRIM(RTRIM(DocCurrencyCode)) = @cur +ORDER BY ValidDate DESC, ValidTime DESC, LastUpdatedDate DESC; +`, sql.Named("pg", priceGroup), sql.Named("cur", currency)).Scan(&headerID) + headerID = strings.TrimSpace(headerID) + if headerID != "" { + logger.Info("save:mssql:header:resolved", + "price_group", priceGroup, + "currency", currency, + "header_id", headerID, + ) + return headerID, nil + } + + // Create header (PriceListNumber pattern: "1-"). + // Note: PriceListNumber is unique (constraint seen as UQ_trPriceListHeader_1), so compute next and retry on collisions. + isTaxIncluded := 0 + if strings.HasPrefix(strings.ToUpper(priceGroup), "B2C-") { + isTaxIncluded = 1 + } + + var priceListNumber string + var err error + for attempt := 1; attempt <= 5; attempt++ { + var nextSeq int64 + if err2 := tx.QueryRowContext(ctx, ` +SELECT ISNULL(MAX(CASE WHEN v.n >= 10000 THEN v.n END), 9999) + 1 +FROM dbo.trPriceListHeader h WITH (UPDLOCK, HOLDLOCK) +CROSS APPLY (VALUES ( + SUBSTRING(LTRIM(RTRIM(h.PriceListNumber)), + CHARINDEX('-', LTRIM(RTRIM(h.PriceListNumber))) + 1, + 50) +)) s(sfx) +CROSS APPLY (VALUES ( + CASE + WHEN s.sfx NOT LIKE '%[^0-9]%' THEN CAST(s.sfx AS BIGINT) + ELSE NULL + END +)) v(n) +WHERE LTRIM(RTRIM(h.PriceListNumber)) LIKE '1-%' + AND CHARINDEX('-', LTRIM(RTRIM(h.PriceListNumber))) > 0; +`).Scan(&nextSeq); err2 != nil { + // If we cannot compute the next sequence (SQL dialect/version), log and fall back to the starting point. + logger.Error("save:mssql:header:nextseq:error", + "price_group", priceGroup, + "currency", currency, + "attempt", attempt, + "err", err2, + ) + nextSeq = 10000 + } + if nextSeq <= 0 { + nextSeq = 10000 + } + if nextSeq < 10000 { + nextSeq = 10000 + } + priceListNumber = fmt.Sprintf("1-%d", nextSeq) + + _, err = tx.ExecContext(ctx, ` +DECLARE @HeaderID UNIQUEIDENTIFIER = NEWID(); + +INSERT INTO dbo.trPriceListHeader ( + PriceListHeaderID, + PriceListNumber, + PriceListDate, + PriceListTime, + PriceListTypeCode, + CompanyCode, + PriceGroupCode, + ValidDate, + ValidTime, + DocCurrencyCode, + Description, + IsTaxIncluded, + IsCompleted, + IsPrinted, + IsLocked, + IsConfirmed, + ConfirmedUserName, + ConfirmedDate, + ApplicationCode, + ApplicationID, + CreatedUserName, + CreatedDate, + LastUpdatedUserName, + LastUpdatedDate +) +VALUES ( + @HeaderID, + @PriceListNumber, + CONVERT(date, GETDATE()), + '00:00:00', + '', + 1, + @PriceGroupCode, + CONVERT(date, GETDATE()), + '00:00:00', + @Currency, + @Description, + @IsTaxIncluded, + 1, + 0, + 0, + 1, + @UserName, + GETDATE(), + 'Price', + CONVERT(NVARCHAR(36), @HeaderID), + @UserName, + GETDATE(), + @UserName, + GETDATE() +); +`, sql.Named("PriceListNumber", priceListNumber), + sql.Named("PriceGroupCode", priceGroup), + sql.Named("Currency", currency), + sql.Named("Description", priceGroup), + sql.Named("IsTaxIncluded", isTaxIncluded), + sql.Named("UserName", username), + ) + if err == nil { + break + } + + low := strings.ToLower(err.Error()) + if strings.Contains(low, "uq_trpricelistheader_1") || strings.Contains(low, "duplicate key") { + logger.Warn("save:mssql:header:create:collision", + "price_group", priceGroup, + "currency", currency, + "price_list_number", priceListNumber, + "attempt", attempt, + "err", err, + ) + time.Sleep(time.Duration(20*attempt) * time.Millisecond) + continue + } + return "", fmt.Errorf("create trPriceListHeader failed for PriceGroupCode=%s currency=%s: %w", priceGroup, currency, err) + } + if err != nil { + return "", fmt.Errorf("create trPriceListHeader failed for PriceGroupCode=%s currency=%s: %w", priceGroup, currency, err) + } + + // Re-read header id. + err = tx.QueryRowContext(ctx, ` +SELECT TOP (1) CONVERT(NVARCHAR(36), PriceListHeaderID) +FROM dbo.trPriceListHeader WITH (NOLOCK) +WHERE CompanyCode = 1 + AND LTRIM(RTRIM(PriceGroupCode)) = @pg + AND LTRIM(RTRIM(DocCurrencyCode)) = @cur +ORDER BY CreatedDate DESC, LastUpdatedDate DESC; +`, sql.Named("pg", priceGroup), sql.Named("cur", currency)).Scan(&headerID) + if err != nil { + return "", fmt.Errorf("create header ok but cannot re-read header id: %w", err) + } + headerID = strings.TrimSpace(headerID) + if headerID == "" { + return "", fmt.Errorf("create header ok but header id is empty") + } + + logger.Info("save:mssql:header:created", + "price_group", priceGroup, + "currency", currency, + "header_id", headerID, + "price_list_number", priceListNumber, + ) + return headerID, nil +} + +func PostProductPricingSaveHandler(pg *sql.DB, ml *mailer.GraphMailer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + traceID := utils.TraceIDFromRequest(r) + w.Header().Set("X-Trace-ID", traceID) + + claims, ok := auth.GetClaimsFromContext(r.Context()) + if !ok || claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var payload productPricingSavePayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + if len(payload.Items) == 0 { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "saved": 0}) + return + } + + // Basic validation early. + for _, it := range payload.Items { + if strings.TrimSpace(it.ProductCode) == "" { + http.Error(w, "product_code is required", http.StatusBadRequest) + return + } + if it.BasePriceUsd < 0 || it.BasePriceTry < 0 { + http.Error(w, "base prices must be >= 0", http.StatusBadRequest) + return + } + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) + defer cancel() + ctx = utils.ContextWithTraceID(ctx, traceID) + logger := utils.SlogFromContext(ctx).With("handler", "product-pricing.save", "trace_id", traceID, "user", claims.Username, "user_id", claims.ID) + + mssql := db.GetDB() + if mssql == nil { + http.Error(w, "mssql not connected", http.StatusInternalServerError) + return + } + + pgTx, err := pg.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "pg transaction start error", http.StatusInternalServerError) + return + } + defer pgTx.Rollback() + + msTx, err := mssql.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "mssql transaction start error", http.StatusInternalServerError) + return + } + defer msTx.Rollback() + + // Serialize writes to pricing tables in PG to avoid contention with other pricing jobs. + if _, err := pgTx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(2001, 1)`); err != nil { + http.Error(w, "pg advisory lock error", http.StatusInternalServerError) + return + } + + savedPG := 0 + savedMSSQL := 0 + missingPG := 0 + missingMSSQL := 0 + + // Load mapping tables once. + pgMap := map[string]map[int]int{} // currency -> level -> sdprcgrp_id + nebimMap := map[string]map[int]string{} // currency -> level -> price_group_code + + { + rows, err := pgTx.QueryContext(ctx, ` +SELECT currency, level_no, COALESCE(sdprcgrp_id, 0) +FROM mk_price_target_map_pg +WHERE is_active = TRUE +`) + if err == nil { + for rows.Next() { + var cur string + var level int + var grp int + if err := rows.Scan(&cur, &level, &grp); err != nil { + _ = rows.Close() + http.Error(w, "pg map scan error", http.StatusInternalServerError) + return + } + cur = strings.ToUpper(strings.TrimSpace(cur)) + if cur == "" || level <= 0 || level > 6 || grp <= 0 { + continue + } + // In this setup sdprcgrp_id is expected to be 1..6. Guard against stale/invalid mappings. + if grp < 1 || grp > 6 { + continue + } + if pgMap[cur] == nil { + pgMap[cur] = map[int]int{} + } + pgMap[cur][level] = grp + } + _ = rows.Close() + } + } + { + rows, err := pgTx.QueryContext(ctx, ` +SELECT currency, level_no, COALESCE(NULLIF(BTRIM(price_group_code), ''), '') +FROM mk_price_target_map_nebim +WHERE is_active = TRUE +`) + if err == nil { + for rows.Next() { + var cur string + var level int + var code string + if err := rows.Scan(&cur, &level, &code); err != nil { + _ = rows.Close() + http.Error(w, "nebim map scan error", http.StatusInternalServerError) + return + } + cur = strings.ToUpper(strings.TrimSpace(cur)) + code = strings.TrimSpace(code) + if cur == "" || level <= 0 || level > 6 || code == "" { + continue + } + if nebimMap[cur] == nil { + nebimMap[cur] = map[int]string{} + } + nebimMap[cur][level] = code + } + _ = rows.Close() + } + } + + changed := make(map[string]struct{}, len(payload.Items)) + + // In-request cache to avoid repeating expensive dim resolution work. + // Key: "|" where token is uppercased/trimmed. + dimTokenLocalCache := make(map[string]int64, 256) + + type dimCombo struct { + Dim1 int64 + Dim3 sql.NullInt64 + } + + type sdprcWriteRow struct { + Currency string `json:"currency"` + SdprcGrpID int `json:"sdprcgrp_id"` + Dim1 int64 `json:"dim1"` + Dim3 *int64 `json:"dim3"` + Price float64 `json:"price"` + } + + loadDimCombosFromCache := func(productCode string) ([]dimCombo, error) { + productCode = strings.TrimSpace(productCode) + if productCode == "" { + return nil, nil + } + rows, err := pgTx.QueryContext(ctx, ` +SELECT dim1, dim3 +FROM mk_mmitem_dim_combo +WHERE product_code = $1 +ORDER BY dim1, dim3_key +`, productCode) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]dimCombo, 0, 32) + for rows.Next() { + var d1 int64 + var d3 sql.NullInt64 + if err := rows.Scan(&d1, &d3); err != nil { + return nil, err + } + if d1 <= 0 { + continue + } + out = append(out, dimCombo{Dim1: d1, Dim3: d3}) + } + return out, rows.Err() + } + + parseDimID := func(s string) (int64, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + // tolerate leading zeros like "001" + s2 := strings.TrimLeft(s, "0") + if s2 == "" { + s2 = "0" + } + n, err := strconv.ParseInt(s2, 10, 64) + if err != nil || n <= 0 { + return 0, false + } + return n, true + } + + type queryRower interface { + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + } + + resolveDimvalFromToken := func(q queryRower, column, token string) (int64, bool) { + token = strings.ToUpper(normalizeDimParam(token)) + if token == "" { + return 0, false + } + cacheKey := column + "|" + token + if v, ok := dimTokenLocalCache[cacheKey]; ok { + return v, v > 0 + } + + // Fast path: persistent token->id mapping table. + { + var id int64 + if err := pgTx.QueryRowContext(ctx, ` +SELECT dim_id +FROM mk_dim_token_map +WHERE dim_column = $1 AND token = $2 +`, column, token).Scan(&id); err == nil && id > 0 { + dimTokenLocalCache[cacheKey] = id + return id, true + } + } + + patterns := buildNameLikePatterns(token) + if len(patterns) == 0 { + dimTokenLocalCache[cacheKey] = 0 + return 0, false + } + + query := fmt.Sprintf(` +SELECT x.dimv +FROM ( + SELECT COALESCE(%s::text, '') AS dimv, COUNT(*) AS cnt + FROM dfblob + WHERE src_table='mmitem' + AND typ='img' + AND COALESCE(%s::text, '') <> '' + AND ( + UPPER(COALESCE(file_name,'')) LIKE $1 OR + UPPER(COALESCE(file_name,'')) LIKE $2 OR + UPPER(COALESCE(file_name,'')) LIKE $3 OR + UPPER(COALESCE(file_name,'')) LIKE $4 OR + UPPER(COALESCE(file_name,'')) LIKE $5 OR + UPPER(COALESCE(file_name,'')) LIKE $6 + ) + GROUP BY COALESCE(%s::text, '') +) x +ORDER BY x.cnt DESC, x.dimv +LIMIT 1 +`, column, column, column) + var v string + if err := q.QueryRowContext(ctx, + query, + patterns[0], + patterns[1], + patterns[2], + patterns[3], + patterns[4], + patterns[5], + ).Scan(&v); err != nil { + dimTokenLocalCache[cacheKey] = 0 + return 0, false + } + v = normalizeDimParam(v) + if v == "" { + dimTokenLocalCache[cacheKey] = 0 + return 0, false + } + id, ok := parseDimID(v) + if !ok { + dimTokenLocalCache[cacheKey] = 0 + return 0, false + } + + // Persist for future requests (best-effort). + _, _ = pgTx.ExecContext(ctx, ` +INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at) +VALUES ($1,$2,$3,now()) +ON CONFLICT (dim_column, token) +DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at +`, column, token, id) + + dimTokenLocalCache[cacheKey] = id + return id, true + } + + loadDimsFromMssqlStock := func(productCode string) ([]dimCombo, error) { + started := time.Now() + if db.MssqlDB == nil { + return nil, fmt.Errorf("mssql not ready") + } + rows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductVariantDimsForPricing, productCode) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]dimCombo, 0, 32) + seen := make(map[string]struct{}, 64) + readRows := 0 + resolvedDim1 := 0 + resolvedDim3 := 0 + for rows.Next() { + readRows++ + var colorCode, dim1Code, dim3Code string + if err := rows.Scan(&colorCode, &dim1Code, &dim3Code); err != nil { + return nil, err + } + // Resolve to PG dim ids (e-commerce expects integer ids, e.g. dim1=82). + d1 := int64(0) + if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim1Code); ok { + d1 = id + resolvedDim1++ + } else if id, ok := resolveDimvalFromToken(pgTx, "dimval1", colorCode); ok { + d1 = id + resolvedDim1++ + } + if d1 <= 0 { + continue + } + var d3 sql.NullInt64 + if id, ok := resolveDimvalFromToken(pgTx, "dimval3", dim3Code); ok { + d3 = sql.NullInt64{Int64: id, Valid: true} + resolvedDim3++ + } + key := fmt.Sprintf("%d|%d", d1, func() int64 { + if d3.Valid { + return d3.Int64 + } + return 0 + }()) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, dimCombo{Dim1: d1, Dim3: d3}) + } + if err := rows.Err(); err != nil { + return nil, err + } + logger.Info("save:pg:dims:mssql:resolved", + "product_code", strings.TrimSpace(productCode), + "rows_read", readRows, + "dims", len(out), + "resolved_dim1", resolvedDim1, + "resolved_dim3", resolvedDim3, + "duration_ms", time.Since(started).Milliseconds(), + ) + return out, nil + } + + upsertDimCombosCache := func(productCode string, dims []dimCombo) error { + productCode = strings.TrimSpace(productCode) + if productCode == "" || len(dims) == 0 { + return nil + } + for _, d := range dims { + _, err := pgTx.ExecContext(ctx, ` +INSERT INTO mk_mmitem_dim_combo (product_code, dim1, dim3, updated_at) +VALUES ($1,$2,$3,now()) +ON CONFLICT (product_code, dim1, dim3_key) +DO UPDATE SET updated_at = EXCLUDED.updated_at +`, productCode, d.Dim1, func() any { + if d.Dim3.Valid { + return d.Dim3.Int64 + } + return nil + }()) + if err != nil { + return err + } + } + return nil + } + + bulkAppendOnlyInsertSdprc := func(mmItemID int64, productCode string, rows []sdprcWriteRow) (int, error) { + if mmItemID <= 0 { + return 0, fmt.Errorf("invalid mmitem_id") + } + if len(rows) == 0 { + return 0, nil + } + raw, err := json.Marshal(rows) + if err != nil { + return 0, err + } + + q := ` +WITH input AS ( + SELECT * + FROM jsonb_to_recordset($1::jsonb) AS x(currency text, sdprcgrp_id int, dim1 bigint, dim3 bigint, price float8) +), +norm AS ( + SELECT + UPPER(NULLIF(BTRIM(currency), '')) AS currency, + COALESCE(sdprcgrp_id, 0) AS sdprcgrp_id, + COALESCE(dim1, 0) AS dim1, + dim3 AS dim3, + COALESCE(price, 0) AS price + FROM input +), +filtered AS ( + SELECT * + FROM norm + WHERE currency IN ('USD','EUR','TRY') + AND sdprcgrp_id BETWEEN 1 AND 6 + AND dim1 > 0 + AND price > 0 +), +latest AS ( + SELECT DISTINCT ON (s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0)) + s.sdprcgrp_id, + s.crn, + s.dim1, + s.dim3, + s.prc + FROM sdprc s + WHERE s.mmitem_id = $2 + AND (s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0)) IN ( + SELECT sdprcgrp_id, currency, dim1, COALESCE(dim3, 0) FROM filtered + ) + ORDER BY s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0), s.zlins_dttm DESC, s.id DESC +), +to_insert AS ( + SELECT + $2::bigint AS mmitem_id, + f.sdprcgrp_id, + f.currency AS crn, + f.dim1, + f.dim3, + f.price AS prc + FROM filtered f + LEFT JOIN latest l + ON l.sdprcgrp_id = f.sdprcgrp_id + AND l.crn = f.currency + AND l.dim1 = f.dim1 + AND ((l.dim3 IS NULL AND f.dim3 IS NULL) OR l.dim3 = f.dim3) + WHERE l.prc IS NULL OR l.prc IS DISTINCT FROM f.price +), +ins AS ( + INSERT INTO sdprc (mmitem_id, sdprcgrp_id, crn, dim1, dim3, prc, zlins_dttm) + SELECT mmitem_id, sdprcgrp_id, crn, dim1, dim3, prc, now() + FROM to_insert + RETURNING 1 +) +SELECT COUNT(*)::int FROM ins; +` + var inserted int + if err := pgTx.QueryRowContext(ctx, q, raw, mmItemID).Scan(&inserted); err != nil { + return 0, err + } + if inserted > 0 { + savedPG += inserted + changed[productCode] = struct{}{} + } + return inserted, nil + } + + // MSSQL memoization: reduce chatter for large batches. + // header id cache key: "|" + msHeaderIDCache := make(map[string]string, 64) + // next sort cache key: "" + msHeaderNextSort := make(map[string]int64, 64) + + type msLatestKey struct { + Cur string + PriceGroup string + } + + loadLatestPricesForProduct := func(productCode string, pairs []msLatestKey) (map[string]float64, map[string]bool) { + out := make(map[string]float64, len(pairs)) + ok := make(map[string]bool, len(pairs)) + + productCode = strings.TrimSpace(productCode) + if productCode == "" || len(pairs) == 0 { + return out, ok + } + + conds := make([]string, 0, len(pairs)) + args := []any{sql.Named("ItemCode", productCode)} + for i, p := range pairs { + pg := strings.TrimSpace(p.PriceGroup) + cur := strings.ToUpper(strings.TrimSpace(p.Cur)) + if pg == "" || (cur != "USD" && cur != "EUR" && cur != "TRY") { + continue + } + args = append(args, + sql.Named(fmt.Sprintf("pg%d", i), pg), + sql.Named(fmt.Sprintf("cur%d", i), cur), + ) + conds = append(conds, + fmt.Sprintf("(LTRIM(RTRIM(PriceGroupCode)) = @pg%d AND LTRIM(RTRIM(DocCurrencyCode)) = @cur%d)", i, i), + ) + } + if len(conds) == 0 { + return out, ok + } + + q := fmt.Sprintf(` +SELECT PriceGroupCode, DocCurrencyCode, Price +FROM ( + SELECT + LTRIM(RTRIM(PriceGroupCode)) AS PriceGroupCode, + LTRIM(RTRIM(DocCurrencyCode)) AS DocCurrencyCode, + CAST(Price AS FLOAT) AS Price, + ROW_NUMBER() OVER ( + PARTITION BY LTRIM(RTRIM(PriceGroupCode)), LTRIM(RTRIM(DocCurrencyCode)) + ORDER BY ValidDate DESC, ValidTime DESC, LastUpdatedDate DESC + ) AS rn + FROM dbo.trPriceListLine WITH(NOLOCK) + WHERE ItemTypeCode = 1 + AND LTRIM(RTRIM(ItemCode)) = @ItemCode + AND ISNULL(IsDisabled, 0) = 0 + AND (%s) +) x +WHERE rn = 1; +`, strings.Join(conds, " OR ")) + + rows, err := msTx.QueryContext(ctx, q, args...) + if err != nil { + logger.Warn("save:mssql:latest:prefetch:error", "product_code", productCode, "err", err) + return out, ok + } + defer rows.Close() + + for rows.Next() { + var pg, cur string + var price float64 + if err := rows.Scan(&pg, &cur, &price); err != nil { + logger.Warn("save:mssql:latest:prefetch:scan:error", "product_code", productCode, "err", err) + return out, ok + } + pg = strings.TrimSpace(pg) + cur = strings.ToUpper(strings.TrimSpace(cur)) + k := cur + "|" + pg + out[k] = price + ok[k] = true + } + return out, ok + } + + // Helper: append-only Nebim price list line (insert new row when price changes). + // Resolve PriceListHeaderID from trPriceListHeader (source of truth). + // If header does not exist for the given PriceGroupCode+Currency, create it, then insert lines under that header. + upsertPriceListLine := func(productCode string, currency string, priceGroup string, price float64, latest map[string]float64, latestOK map[string]bool) (bool, error) { + currency = strings.ToUpper(strings.TrimSpace(currency)) + priceGroup = strings.TrimSpace(priceGroup) + if price <= 0 { + return false, nil + } + if currency != "USD" && currency != "EUR" && currency != "TRY" { + return false, fmt.Errorf("invalid currency") + } + if priceGroup == "" { + return false, fmt.Errorf("empty price group") + } + + // Resolve or create header id for that group/currency (memoized). + headerKey := currency + "|" + priceGroup + headerID := strings.TrimSpace(msHeaderIDCache[headerKey]) + if headerID == "" { + var err error + headerID, err = resolveOrCreatePriceListHeaderID(ctx, msTx, priceGroup, currency, claims.Username, logger) + if err != nil { + return false, err + } + msHeaderIDCache[headerKey] = headerID + } + + // If latest line already has the same price, no-op (prefer prefetch map). + if latest != nil && latestOK != nil && latestOK[headerKey] { + if curLatest, ok := latest[headerKey]; ok && math.Abs(curLatest-price) < 1e-9 { + return false, nil + } + } else { + // Fallback: query latest for this key if not prefetched. + var latestPrice sql.NullFloat64 + _ = msTx.QueryRowContext(ctx, ` +SELECT TOP (1) CAST(Price AS FLOAT) +FROM dbo.trPriceListLine WITH(NOLOCK) +WHERE ItemTypeCode = 1 + AND LTRIM(RTRIM(ItemCode)) = @p1 + AND LTRIM(RTRIM(DocCurrencyCode)) = @p2 + AND LTRIM(RTRIM(PriceGroupCode)) = @p3 + AND ISNULL(IsDisabled, 0) = 0 +ORDER BY ValidDate DESC, ValidTime DESC, LastUpdatedDate DESC; +`, sql.Named("p1", productCode), sql.Named("p2", currency), sql.Named("p3", priceGroup)).Scan(&latestPrice) + if latestPrice.Valid && math.Abs(latestPrice.Float64-price) < 1e-9 { + return false, nil + } + } + + // SortOrder: append inside header. + nextSort := msHeaderNextSort[headerID] + if nextSort <= 0 { + _ = msTx.QueryRowContext(ctx, ` +SELECT ISNULL(MAX(SortOrder), 0) + 1 +FROM dbo.trPriceListLine WITH(NOLOCK) +WHERE PriceListHeaderID = CONVERT(UNIQUEIDENTIFIER, @p1); +`, sql.Named("p1", headerID)).Scan(&nextSort) + if nextSort <= 0 { + nextSort = 1 + } + } + msHeaderNextSort[headerID] = nextSort + 1 + + // Insert minimal line. + _, err := msTx.ExecContext(ctx, ` +INSERT INTO dbo.trPriceListLine ( + PriceListLineID, + SortOrder, + ItemTypeCode, + ItemCode, + ColorCode, + ItemDim1Code, + ItemDim2Code, + ItemDim3Code, + UnitOfMeasureCode, + PaymentPlanCode, + LineDescription, + DocCurrencyCode, + Price, + IsDisabled, + DisableDate, + CompanyCode, + PriceGroupCode, + ValidDate, + ValidTime, + PriceListHeaderID, + CreatedUserName, + CreatedDate, + LastUpdatedUserName, + LastUpdatedDate +) +VALUES ( + NEWID(), + @SortOrder, + 1, + @ItemCode, + '', + '', + '', + '', + 'AD', + '', + '', + @Currency, + @Price, + 0, + '1900-01-01', + 1, + @PriceGroupCode, + CONVERT(date, GETDATE()), + '00:00:00', + CONVERT(uniqueidentifier, @HeaderID), + @UserName, + GETDATE(), + @UserName, + GETDATE() +); +`, sql.Named("SortOrder", nextSort), + sql.Named("ItemCode", productCode), + sql.Named("Currency", currency), + sql.Named("Price", price), + sql.Named("PriceGroupCode", priceGroup), + sql.Named("HeaderID", headerID), + sql.Named("UserName", claims.Username), + ) + if err != nil { + return false, err + } + return true, nil + } + + for _, it := range payload.Items { + code := strings.TrimSpace(it.ProductCode) + if code == "" { + continue + } + + var latestMap map[string]float64 + var latestOK map[string]bool + + var mmItemID int64 + if err := pgTx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmItemID); err != nil { + // If missing in PG, we can still save MSSQL tiers; PG write will be skipped. + mmItemID = 0 + } + dims := []dimCombo{} + // Prefer cached dim combos (fast). If not present, load from Nebim stock query (used by product-stock-query UI). + if mmItemID > 0 { + cacheStarted := time.Now() + cached, cacheErr := loadDimCombosFromCache(code) + if cacheErr == nil && len(cached) > 0 { + dims = cached + logger.Info("save:pg:dims:cache:hit", + "product_code", code, + "dims", len(dims), + "duration_ms", time.Since(cacheStarted).Milliseconds(), + ) + } else if cacheErr != nil { + logger.Error("save:pg:dims:cache-load:error", "product_code", code, "err", cacheErr) + } else { + logger.Info("save:pg:dims:cache:miss", + "product_code", code, + "duration_ms", time.Since(cacheStarted).Milliseconds(), + ) + } + + if len(dims) == 0 { + d, err := loadDimsFromMssqlStock(code) + if err != nil { + logger.Error("save:pg:dims:mssql:error", "product_code", code, "err", err) + } else { + dims = d + if err := upsertDimCombosCache(code, dims); err != nil { + logger.Error("save:pg:dims:cache:error", "product_code", code, "err", err) + } + } + } + } + + // Tier prices in PG sdprc + Nebim price list lines (mapped). + type tier struct { + Cur string + Level int + Price float64 + } + tiers := []tier{ + {"USD", 1, it.USD1}, {"USD", 2, it.USD2}, {"USD", 3, it.USD3}, {"USD", 4, it.USD4}, {"USD", 5, it.USD5}, {"USD", 6, it.USD6}, + {"EUR", 1, it.EUR1}, {"EUR", 2, it.EUR2}, {"EUR", 3, it.EUR3}, {"EUR", 4, it.EUR4}, {"EUR", 5, it.EUR5}, {"EUR", 6, it.EUR6}, + {"TRY", 1, it.TRY1}, {"TRY", 2, it.TRY2}, {"TRY", 3, it.TRY3}, {"TRY", 4, it.TRY4}, {"TRY", 5, it.TRY5}, {"TRY", 6, it.TRY6}, + } + + // Prefetch MSSQL latest prices for all relevant pairs for this product. + // This turns N tier "TOP 1" lookups into a single query per product. + { + msPairs := make([]msLatestKey, 0, 24) + seen := make(map[string]struct{}, 32) + addPair := func(cur, pg string) { + cur = strings.ToUpper(strings.TrimSpace(cur)) + pg = strings.TrimSpace(pg) + if pg == "" { + return + } + k := cur + "|" + pg + if _, ok := seen[k]; ok { + return + } + seen[k] = struct{}{} + msPairs = append(msPairs, msLatestKey{Cur: cur, PriceGroup: pg}) + } + if it.BasePriceUsd > 0 { + addPair("USD", "TM-USD") + } + if it.BasePriceTry > 0 { + addPair("TRY", "TM-TRY") + } + for _, t := range tiers { + if t.Price <= 0 { + continue + } + nebimGrp := "" + if nebimMap[t.Cur] != nil { + nebimGrp = nebimMap[t.Cur][t.Level] + } + if nebimGrp == "" { + continue + } + addPair(t.Cur, nebimGrp) + } + latestMap, latestOK = loadLatestPricesForProduct(code, msPairs) + } + + // Base prices in Nebim price lists. + { + ch, err := upsertPriceListLine(code, "USD", "TM-USD", it.BasePriceUsd, latestMap, latestOK) + if err != nil { + logger.Error("save:mssql:base-usd:error", "product_code", code, "err", err) + http.Error(w, "mssql base price save error: "+err.Error(), http.StatusInternalServerError) + return + } + if ch { + changed[code] = struct{}{} + savedMSSQL++ + } + + ch, err = upsertPriceListLine(code, "TRY", "TM-TRY", it.BasePriceTry, latestMap, latestOK) + if err != nil { + logger.Error("save:mssql:base-try:error", "product_code", code, "err", err) + http.Error(w, "mssql base price save error: "+err.Error(), http.StatusInternalServerError) + return + } + if ch { + changed[code] = struct{}{} + savedMSSQL++ + } + } + + // PG write: bulk append-only insert across dims (fast). + if mmItemID > 0 && len(dims) > 0 { + writeRows := make([]sdprcWriteRow, 0, len(dims)*len(tiers)) + for _, t := range tiers { + if t.Price <= 0 { + continue + } + pgGrp := 0 + if pgMap[t.Cur] != nil { + pgGrp = pgMap[t.Cur][t.Level] + } + if pgGrp <= 0 { + pgGrp = t.Level + } + for _, dc := range dims { + var d3 *int64 + if dc.Dim3.Valid { + v := dc.Dim3.Int64 + d3 = &v + } + writeRows = append(writeRows, sdprcWriteRow{ + Currency: t.Cur, + SdprcGrpID: pgGrp, + Dim1: dc.Dim1, + Dim3: d3, + Price: t.Price, + }) + } + } + if len(writeRows) > 0 { + startPG := time.Now() + inserted, err := bulkAppendOnlyInsertSdprc(mmItemID, code, writeRows) + if err != nil { + logger.Error("save:pg:sdprc:bulk:error", "product_code", code, "dims", len(dims), "rows", len(writeRows), "err", err) + http.Error(w, "postgres tier save error: "+err.Error(), http.StatusInternalServerError) + return + } + logger.Info("save:pg:sdprc:bulk:ok", "product_code", code, "dims", len(dims), "rows", len(writeRows), "inserted", inserted, "duration_ms", time.Since(startPG).Milliseconds()) + } + } else { + for _, t := range tiers { + if t.Price > 0 { + missingPG++ + logger.Warn("save:pg:sdprc:skip:no-dims", "product_code", code, "currency", t.Cur, "level", t.Level) + } + } + } + + // MSSQL tier writes (mapped). + for _, t := range tiers { + nebimGrp := "" + if nebimMap[t.Cur] != nil { + nebimGrp = nebimMap[t.Cur][t.Level] + } + if nebimGrp == "" { + if t.Price > 0 { + missingMSSQL++ + } + continue + } + msChanged, err := upsertPriceListLine(code, t.Cur, nebimGrp, t.Price, latestMap, latestOK) + if err != nil { + logger.Error("save:mssql:tier:error", "product_code", code, "currency", t.Cur, "level", t.Level, "price_group", nebimGrp, "err", err) + http.Error(w, "mssql tier save error: "+err.Error(), http.StatusInternalServerError) + return + } + if msChanged { + changed[code] = struct{}{} + savedMSSQL++ + } + } + } + + // Delta queue: only products with an explicit price change record should be processed by delta jobs. + { + codes := make([]string, 0, len(changed)) + for c := range changed { + codes = append(codes, c) + } + if _, err := queries.EnqueuePriceRecalc(ctx, pgTx, codes, "manual_price_save"); err != nil { + logger.Error("save:enqueue:error", "err", err) + http.Error(w, "price recalc enqueue error: "+err.Error(), http.StatusInternalServerError) + return + } + } + + if err := msTx.Commit(); err != nil { + logger.Error("save:mssql:commit:error", "err", err) + http.Error(w, "mssql commit error", http.StatusInternalServerError) + return + } + if err := pgTx.Commit(); err != nil { + logger.Error("save:pg:commit:error", "err", err) + http.Error(w, "postgres commit error", http.StatusInternalServerError) + return + } + + // Post-commit pricing mail: only for actually changed products. + if ml != nil && len(changed) > 0 { + changedCodes := make([]string, 0, len(changed)) + for c := range changed { + changedCodes = append(changedCodes, c) + } + actor := claims.Username + go sendPricingChangeMails(context.Background(), ml, changedCodes, actor) + } + + // Immediate FX delta publish kick (best-effort): run right away for changed products. + // Queue entries are still created for reliability; on success we mark them done to avoid a second pass. + if len(changed) > 0 { + changedCodes := make([]string, 0, len(changed)) + for c := range changed { + changedCodes = append(changedCodes, c) + } + go func(codes []string) { + ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel2() + + written, fxDateYmd, err := queries.PublishDerivedPricesFromAnchor(ctx2, pg, codes, "", false) + if err != nil { + log.Printf("[PricingFxImmediate] publish_error codes=%d err=%v", len(codes), err) + return + } + tx2, err := pg.BeginTx(ctx2, nil) + if err == nil { + _, _ = queries.MarkPriceRecalcQueueDoneByProductCodes(ctx2, tx2, codes) + _ = tx2.Commit() + } + log.Printf("[PricingFxImmediate] ok codes=%d sdprc_written=%d fx_date_ymd=%d", len(codes), written, fxDateYmd) + }(changedCodes) + } + + logger.Info("save:done", + "items", len(payload.Items), + "saved_pg", savedPG, + "saved_mssql", savedMSSQL, + "missing_pg", missingPG, + "missing_mssql", missingMSSQL, + "duration_ms", time.Since(started).Milliseconds(), + ) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "saved_pg": savedPG, + "saved_mssql": savedMSSQL, + "missing_pg": missingPG, + "missing_mssql": missingMSSQL, + }) + } +} diff --git a/ui/.quasar/prod-spa/app.js b/ui/.quasar/prod-spa/app.js deleted file mode 100644 index caeaac1..0000000 --- a/ui/.quasar/prod-spa/app.js +++ /dev/null @@ -1,75 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - - - - -import { Quasar } from 'quasar' -import { markRaw } from 'vue' -import RootComponent from 'app/src/App.vue' - -import createStore from 'app/src/stores/index' -import createRouter from 'app/src/router/index' - - - - - -export default async function (createAppFn, quasarUserOptions) { - - - // Create the app instance. - // Here we inject into it the Quasar UI, the router & possibly the store. - const app = createAppFn(RootComponent) - - - - app.use(Quasar, quasarUserOptions) - - - - - const store = typeof createStore === 'function' - ? await createStore({}) - : createStore - - - app.use(store) - - - - - - const router = markRaw( - typeof createRouter === 'function' - ? await createRouter({store}) - : createRouter - ) - - - // make router instance available in store - - store.use(({ store }) => { store.router = router }) - - - - // Expose the app, the router and the store. - // Note that we are not mounting the app here, since bootstrapping will be - // different depending on whether we are in a browser or on the server. - return { - app, - store, - router - } -} diff --git a/ui/.quasar/prod-spa/client-entry.js b/ui/.quasar/prod-spa/client-entry.js deleted file mode 100644 index 5223e2b..0000000 --- a/ui/.quasar/prod-spa/client-entry.js +++ /dev/null @@ -1,158 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - -import { createApp } from 'vue' - - - - - - - -import '@quasar/extras/roboto-font/roboto-font.css' - -import '@quasar/extras/material-icons/material-icons.css' - - - - -// We load Quasar stylesheet file -import 'quasar/dist/quasar.sass' - - - - -import 'src/css/app.css' - - -import createQuasarApp from './app.js' -import quasarUserOptions from './quasar-user-options.js' - - - - - - - - -const publicPath = `/` - - -async function start ({ - app, - router - , store -}, bootFiles) { - - let hasRedirected = false - const getRedirectUrl = url => { - try { return router.resolve(url).href } - catch (err) {} - - return Object(url) === url - ? null - : url - } - const redirect = url => { - hasRedirected = true - - if (typeof url === 'string' && /^https?:\/\//.test(url)) { - window.location.href = url - return - } - - const href = getRedirectUrl(url) - - // continue if we didn't fail to resolve the url - if (href !== null) { - window.location.href = href - window.location.reload() - } - } - - const urlPath = window.location.href.replace(window.location.origin, '') - - for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) { - try { - await bootFiles[i]({ - app, - router, - store, - ssrContext: null, - redirect, - urlPath, - publicPath - }) - } - catch (err) { - if (err && err.url) { - redirect(err.url) - return - } - - console.error('[Quasar] boot error:', err) - return - } - } - - if (hasRedirected === true) return - - - app.use(router) - - - - - - - app.mount('#q-app') - - - -} - -createQuasarApp(createApp, quasarUserOptions) - - .then(app => { - // eventually remove this when Cordova/Capacitor/Electron support becomes old - const [ method, mapFn ] = Promise.allSettled !== void 0 - ? [ - 'allSettled', - bootFiles => bootFiles.map(result => { - if (result.status === 'rejected') { - console.error('[Quasar] boot error:', result.reason) - return - } - return result.value.default - }) - ] - : [ - 'all', - bootFiles => bootFiles.map(entry => entry.default) - ] - - return Promise[ method ]([ - - import(/* webpackMode: "eager" */ 'boot/dayjs'), - - import(/* webpackMode: "eager" */ 'boot/locale'), - - import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard') - - ]).then(bootFiles => { - const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function') - start(app, boot) - }) - }) - diff --git a/ui/.quasar/prod-spa/client-prefetch.js b/ui/.quasar/prod-spa/client-prefetch.js deleted file mode 100644 index 9bbe3c5..0000000 --- a/ui/.quasar/prod-spa/client-prefetch.js +++ /dev/null @@ -1,116 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - - -import App from 'app/src/App.vue' -let appPrefetch = typeof App.preFetch === 'function' - ? App.preFetch - : ( - // Class components return the component options (and the preFetch hook) inside __c property - App.__c !== void 0 && typeof App.__c.preFetch === 'function' - ? App.__c.preFetch - : false - ) - - -function getMatchedComponents (to, router) { - const route = to - ? (to.matched ? to : router.resolve(to).route) - : router.currentRoute.value - - if (!route) { return [] } - - const matched = route.matched.filter(m => m.components !== void 0) - - if (matched.length === 0) { return [] } - - return Array.prototype.concat.apply([], matched.map(m => { - return Object.keys(m.components).map(key => { - const comp = m.components[key] - return { - path: m.path, - c: comp - } - }) - })) -} - -export function addPreFetchHooks ({ router, store, publicPath }) { - // Add router hook for handling preFetch. - // Doing it after initial route is resolved so that we don't double-fetch - // the data that we already have. Using router.beforeResolve() so that all - // async components are resolved. - router.beforeResolve((to, from, next) => { - const - urlPath = window.location.href.replace(window.location.origin, ''), - matched = getMatchedComponents(to, router), - prevMatched = getMatchedComponents(from, router) - - let diffed = false - const preFetchList = matched - .filter((m, i) => { - return diffed || (diffed = ( - !prevMatched[i] || - prevMatched[i].c !== m.c || - m.path.indexOf('/:') > -1 // does it has params? - )) - }) - .filter(m => m.c !== void 0 && ( - typeof m.c.preFetch === 'function' - // Class components return the component options (and the preFetch hook) inside __c property - || (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function') - )) - .map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch) - - - if (appPrefetch !== false) { - preFetchList.unshift(appPrefetch) - appPrefetch = false - } - - - if (preFetchList.length === 0) { - return next() - } - - let hasRedirected = false - const redirect = url => { - hasRedirected = true - next(url) - } - const proceed = () => { - - if (hasRedirected === false) { next() } - } - - - - preFetchList.reduce( - (promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({ - store, - currentRoute: to, - previousRoute: from, - redirect, - urlPath, - publicPath - })), - Promise.resolve() - ) - .then(proceed) - .catch(e => { - console.error(e) - proceed() - }) - }) -} diff --git a/ui/.quasar/prod-spa/quasar-user-options.js b/ui/.quasar/prod-spa/quasar-user-options.js deleted file mode 100644 index ac1dae3..0000000 --- a/ui/.quasar/prod-spa/quasar-user-options.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - -import lang from 'quasar/lang/tr.js' - - - -import {Loading,Dialog,Notify} from 'quasar' - - - -export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} } - diff --git a/ui/quasar.config.js.temporary.compiled.1780488176351.mjs b/ui/quasar.config.js.temporary.compiled.1781721394097.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1780488176351.mjs rename to ui/quasar.config.js.temporary.compiled.1781721394097.mjs diff --git a/ui/src/pages/BrandGroupCurrency.vue b/ui/src/pages/BrandGroupCurrency.vue new file mode 100644 index 0000000..c13807f --- /dev/null +++ b/ui/src/pages/BrandGroupCurrency.vue @@ -0,0 +1,158 @@ + + + diff --git a/ui/src/pages/PricingRules.vue b/ui/src/pages/PricingRules.vue index 99e9c93..10c40c5 100644 --- a/ui/src/pages/PricingRules.vue +++ b/ui/src/pages/PricingRules.vue @@ -86,6 +86,15 @@ + + {{ csvImportStatus.message }} + +
+ + + +
+ + @@ -306,6 +345,7 @@ const fileInputRef = ref(null) const selectedKeyMap = ref({}) const copySelectedKeys = ref([]) const tablePagination = ref({ rowsPerPage: 0, sortBy: 'urun_ilk_grubu', descending: false }) +const csvImportStatus = ref(null) // { type: 'positive'|'warning', message: string, at: string } let emptyRetryTimer = null const numericFields = new Set([ @@ -313,6 +353,8 @@ const numericFields = new Set([ 'usd_base', 'usd1', 'usd2', 'usd3', 'usd4', 'usd5', 'usd6', 'usd_wholesale_step', 'usd_retail_step', 'eur_base', 'eur1', 'eur2', 'eur3', 'eur4', 'eur5', 'eur6', 'eur_wholesale_step', 'eur_retail_step' ]) +const retailModeFields = new Set(['try_retail_mode', 'usd_retail_mode', 'eur_retail_mode']) +const retailModeOptions = ['STEP', 'END_99', 'END_49', 'BAND_99', 'BAND_49'] const importKeyFieldLabels = [ ['askili_yan', 'ASKILI YAN'], @@ -328,7 +370,12 @@ const importKeyFieldLabels = [ const importFieldMap = { AKTIF: 'is_active', + 'HESAP AKTIF': 'calc_enabled', + 'PG YAYIN': 'publish_postgres', + 'NEBIM YAYIN': 'publish_nebim', 'TRY TOPTAN YUVARLAMA': 'try_wholesale_step', + 'TRY PERAKENDE MODU': 'try_retail_mode', + 'TRY PERAKENDE DEGERI': 'try_retail_step', 'TRY PERAKENDE YUVARLAMA': 'try_retail_step', 'TRY YUVARLAMA': 'try_wholesale_step', 'TRY TABAN': 'try_base', @@ -339,6 +386,8 @@ const importFieldMap = { 'TRY 5': 'try5', 'TRY 6': 'try6', 'USD TOPTAN YUVARLAMA': 'usd_wholesale_step', + 'USD PERAKENDE MODU': 'usd_retail_mode', + 'USD PERAKENDE DEGERI': 'usd_retail_step', 'USD PERAKENDE YUVARLAMA': 'usd_retail_step', 'USD YUVARLAMA': 'usd_wholesale_step', 'USD TABAN': 'usd_base', @@ -349,6 +398,8 @@ const importFieldMap = { 'USD 5': 'usd5', 'USD 6': 'usd6', 'EUR TOPTAN YUVARLAMA': 'eur_wholesale_step', + 'EUR PERAKENDE MODU': 'eur_retail_mode', + 'EUR PERAKENDE DEGERI': 'eur_retail_step', 'EUR PERAKENDE YUVARLAMA': 'eur_retail_step', 'EUR YUVARLAMA': 'eur_wholesale_step', 'EUR TABAN': 'eur_base', @@ -362,7 +413,8 @@ const importFieldMap = { const multiFilterFields = [ 'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu', 'urun_ana_grubu', - 'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group' + 'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group', + 'try_retail_mode', 'usd_retail_mode', 'eur_retail_mode' ] const multiSelectFilterFieldSet = new Set(multiFilterFields) const numberRangeFilterFieldSet = new Set(numericFields) @@ -387,56 +439,64 @@ function col (name, label, field, width, extra = {}) { } const columns = [ - col('copy_select', 'KOPYA', 'copy_select', 86, { sortable: false, classes: 'copy-selection-col', headerClasses: 'copy-selection-col' }), - col('select', 'KAYDET', 'select', 72, { sortable: false, classes: 'save-selection-col', headerClasses: 'save-selection-col' }), - col('has_rule', 'DURUM', 'has_rule', 62, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('is_active', 'AKTIF', 'is_active', 48, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('askili_yan', 'ASKILI YAN', 'askili_yan', 86, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('kategori', 'KATEGORI', 'kategori', 92, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('urun_ilk_grubu', 'URUN ILK GRUBU', 'urun_ilk_grubu', 100, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('urun_ana_grubu', 'URUN ANA GRUBU', 'urun_ana_grubu', 110, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('urun_alt_grubu', 'URUN ALT GRUBU', 'urun_alt_grubu', 110, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('icerik', 'ICERIK', 'icerik', 90, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('marka', 'MARKA', 'marka', 100, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('brand_code', 'BRAND CODE', 'brand_code', 78, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('brand_group', 'MARKA GRUBU', 'brand_group', 88, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('copy_select', 'KOPYA', 'copy_select', 68, { sortable: false, classes: 'copy-selection-col', headerClasses: 'copy-selection-col' }), + col('select', 'KAYDET', 'select', 58, { sortable: false, classes: 'save-selection-col', headerClasses: 'save-selection-col' }), + col('has_rule', 'DURUM', 'has_rule', 54, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('is_active', 'AKTIF', 'is_active', 42, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('askili_yan', 'ASKILI YAN', 'askili_yan', 72, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('kategori', 'KATEGORI', 'kategori', 76, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('urun_ilk_grubu', 'URUN ILK GRUBU', 'urun_ilk_grubu', 84, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('urun_ana_grubu', 'URUN ANA GRUBU', 'urun_ana_grubu', 90, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('urun_alt_grubu', 'URUN ALT GRUBU', 'urun_alt_grubu', 90, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('icerik', 'ICERIK', 'icerik', 72, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('marka', 'MARKA', 'marka', 80, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('brand_code', 'BRAND CODE', 'brand_code', 68, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('brand_group', 'MARKA GRUBU', 'brand_group', 76, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('anchor_mode', 'ANCHOR MODE', 'anchor_mode', 66, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('calc_enabled', 'HESAP AKTIF', 'calc_enabled', 66, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('publish_postgres', 'PG YAYIN', 'publish_postgres', 62, { classes: 'ps-col', headerClasses: 'ps-col' }), + col('publish_nebim', 'NEBIM YAYIN', 'publish_nebim', 66, { classes: 'ps-col', headerClasses: 'ps-col' }), - col('try_wholesale_step', 'TRY TOPTAN YUVARLAMA', 'try_wholesale_step', 92, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), - col('try_retail_step', 'TRY PERAKENDE YUVARLAMA', 'try_retail_step', 98, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), - col('try_base', 'TRY TABAN', 'try_base', 70, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), - col('try1', 'TRY 1', 'try1', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), - col('try2', 'TRY 2', 'try2', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), - col('try3', 'TRY 3', 'try3', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), - col('try4', 'TRY 4', 'try4', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), - col('try5', 'TRY 5', 'try5', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), - col('try6', 'TRY 6', 'try6', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), + col('try_wholesale_step', 'TRY TOPTAN YUVARLAMA', 'try_wholesale_step', 76, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), + col('try_retail_mode', 'TRY PERAKENDE MODU', 'try_retail_mode', 76, { classes: 'try-col', headerClasses: 'try-col' }), + col('try_retail_step', 'TRY PERAKENDE DEGERI', 'try_retail_step', 78, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), + col('try_base', 'TRY TABAN', 'try_base', 58, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), + col('try1', 'TRY 1', 'try1', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), + col('try2', 'TRY 2', 'try2', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), + col('try3', 'TRY 3', 'try3', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), + col('try4', 'TRY 4', 'try4', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), + col('try5', 'TRY 5', 'try5', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), + col('try6', 'TRY 6', 'try6', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }), - col('usd_wholesale_step', 'USD TOPTAN YUVARLAMA', 'usd_wholesale_step', 92, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), - col('usd_retail_step', 'USD PERAKENDE YUVARLAMA', 'usd_retail_step', 98, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), - col('usd_base', 'USD TABAN', 'usd_base', 70, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), - col('usd1', 'USD 1', 'usd1', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), - col('usd2', 'USD 2', 'usd2', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), - col('usd3', 'USD 3', 'usd3', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), - col('usd4', 'USD 4', 'usd4', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), - col('usd5', 'USD 5', 'usd5', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), - col('usd6', 'USD 6', 'usd6', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), + col('usd_wholesale_step', 'USD TOPTAN YUVARLAMA', 'usd_wholesale_step', 76, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), + col('usd_retail_mode', 'USD PERAKENDE MODU', 'usd_retail_mode', 76, { classes: 'usd-col', headerClasses: 'usd-col' }), + col('usd_retail_step', 'USD PERAKENDE DEGERI', 'usd_retail_step', 78, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), + col('usd_base', 'USD TABAN', 'usd_base', 58, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), + col('usd1', 'USD 1', 'usd1', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), + col('usd2', 'USD 2', 'usd2', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), + col('usd3', 'USD 3', 'usd3', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), + col('usd4', 'USD 4', 'usd4', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), + col('usd5', 'USD 5', 'usd5', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), + col('usd6', 'USD 6', 'usd6', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }), - col('eur_wholesale_step', 'EUR TOPTAN YUVARLAMA', 'eur_wholesale_step', 92, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), - col('eur_retail_step', 'EUR PERAKENDE YUVARLAMA', 'eur_retail_step', 98, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), - col('eur_base', 'EUR TABAN', 'eur_base', 70, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), - col('eur1', 'EUR 1', 'eur1', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), - col('eur2', 'EUR 2', 'eur2', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), - col('eur3', 'EUR 3', 'eur3', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), - col('eur4', 'EUR 4', 'eur4', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), - col('eur5', 'EUR 5', 'eur5', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), - col('eur6', 'EUR 6', 'eur6', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }) + col('eur_wholesale_step', 'EUR TOPTAN YUVARLAMA', 'eur_wholesale_step', 76, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), + col('eur_retail_mode', 'EUR PERAKENDE MODU', 'eur_retail_mode', 76, { classes: 'eur-col', headerClasses: 'eur-col' }), + col('eur_retail_step', 'EUR PERAKENDE DEGERI', 'eur_retail_step', 78, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), + col('eur_base', 'EUR TABAN', 'eur_base', 58, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), + col('eur1', 'EUR 1', 'eur1', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), + col('eur2', 'EUR 2', 'eur2', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), + col('eur3', 'EUR 3', 'eur3', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), + col('eur4', 'EUR 4', 'eur4', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), + col('eur5', 'EUR 5', 'eur5', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }), + col('eur6', 'EUR 6', 'eur6', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }) ] const stickyColumnNames = [ 'copy_select', 'select', 'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu', - 'urun_ana_grubu', 'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group' + 'urun_ana_grubu', 'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group', + 'anchor_mode', 'calc_enabled', 'publish_postgres', 'publish_nebim' ] -const stickyBoundaryColumnName = 'brand_group' +const stickyBoundaryColumnName = 'publish_nebim' const stickyColumnNameSet = new Set(stickyColumnNames) const stickyLeftMap = computed(() => { @@ -466,6 +526,7 @@ const tableStyle = computed(() => ({ function filterDisplayValue (row, field) { if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni' if (field === 'is_active') return row?.is_active ? 'Aktif' : 'Pasif' + if (retailModeFields.has(field)) return String(row?.[field] || 'STEP').trim() || 'STEP' return String(row?.[field] ?? '').trim() } @@ -562,6 +623,10 @@ function normalizeWorksheetRow (source) { _row_key: String(source?.scope_key || source?.pricing_parameter_id || ''), has_rule: Boolean(source?.has_rule), id: String(rule?.id || ''), + anchor_mode: String(rule?.anchor_mode || 'USD'), + calc_enabled: rule?.calc_enabled !== false, + publish_postgres: rule?.publish_postgres !== false, + publish_nebim: rule?.publish_nebim !== false, is_active: rule?.is_active !== false, askili_yan: String(source?.askili_yan || ''), kategori: String(source?.kategori || ''), @@ -572,6 +637,9 @@ function normalizeWorksheetRow (source) { marka: String(source?.marka || ''), brand_code: String(source?.brand_code || ''), brand_group: String(source?.brand_group || ''), + try_retail_mode: String(rule?.try_retail_mode || 'STEP'), + usd_retail_mode: String(rule?.usd_retail_mode || 'STEP'), + eur_retail_mode: String(rule?.eur_retail_mode || 'STEP'), _dirty: false } for (const key of numericFields) { @@ -607,6 +675,11 @@ function isRowSelected (row) { return !!selectedKeyMap.value?.[row._row_key] } +function clearSelections () { + selectedKeyMap.value = {} + copySelectedKeys.value = [] +} + function isCopySelected (row) { return copySelectedKeySet.value.has(row._row_key) } @@ -668,9 +741,17 @@ function updateNumber (row, field, value) { markDirty(row) } +function updateRetailMode (row, field, value) { + row[field] = retailModeOptions.includes(String(value || '').trim()) ? String(value).trim() : 'STEP' + markDirty(row) +} + function exportSortValue (row, field) { if (field === 'has_rule') return row?.has_rule ? 1 : 0 if (field === 'is_active') return row?.is_active ? 1 : 0 + if (field === 'calc_enabled') return row?.calc_enabled ? 1 : 0 + if (field === 'publish_postgres') return row?.publish_postgres ? 1 : 0 + if (field === 'publish_nebim') return row?.publish_nebim ? 1 : 0 if (numericFields.has(field)) return finiteNumber(row?.[field], 0) return String(row?.[field] ?? '') } @@ -678,6 +759,16 @@ function exportSortValue (row, field) { function exportCellValue (row, field) { if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni' if (field === 'is_active') return row?.is_active ? 'Aktif' : 'Pasif' + if (field === 'calc_enabled') return row?.calc_enabled ? 'Aktif' : 'Pasif' + if (field === 'publish_postgres') return row?.publish_postgres ? 'Evet' : 'Hayir' + if (field === 'publish_nebim') return row?.publish_nebim ? 'Evet' : 'Hayir' + // Excel often coerces numeric-looking codes/names; wrap to keep as text when opened/edited in Excel. + if (field === 'brand_code' || field === 'marka') { + const text = String(row?.[field] ?? '').trim() + if (!text) return '' + return `="${text.replaceAll('"', '""')}"` + } + if (retailModeFields.has(field)) return String(row?.[field] || 'STEP').trim() || 'STEP' if (numericFields.has(field)) { const value = row?.[field] if (value === '' || value === null || value === undefined) return '0' @@ -858,10 +949,17 @@ async function onImportFileChange (event) { if (!file) return try { + const startedAt = Date.now() + console.info('[pricing-rules][ui] csv-import:start', { + at: new Date(startedAt).toISOString(), + name: file?.name || '', + size: file?.size || 0 + }) const text = await file.text() const matrix = parseCsvRows(text).filter(row => row.some(cell => String(cell || '').trim() !== '')) if (matrix.length < 2) { Notify.create({ type: 'negative', message: 'CSV bos veya gecersiz' }) + csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: 'CSV bos veya gecersiz.' } return } @@ -883,6 +981,7 @@ async function onImportFileChange (event) { let matched = 0 let updated = 0 let skipped = 0 + const updatedRowKeys = [] for (let i = 1; i < matrix.length; i++) { const csvRow = matrix[i] @@ -912,7 +1011,22 @@ async function onImportFileChange (event) { } continue } - + if (field === 'calc_enabled' || field === 'publish_postgres' || field === 'publish_nebim') { + const next = parseImportedBoolean(rawValue) + if (next !== null && Boolean(target[field]) !== next) { + target[field] = next + rowChanged = true + } + continue + } + if (retailModeFields.has(field)) { + const next = retailModeOptions.includes(normalizeImportText(rawValue)) ? normalizeImportText(rawValue) : 'STEP' + if (String(target[field] || 'STEP') !== next) { + target[field] = next + rowChanged = true + } + continue + } const next = parseImportedNumber(rawValue) if (Number(target[field] ?? 0) !== next) { target[field] = next @@ -922,21 +1036,48 @@ async function onImportFileChange (event) { if (rowChanged) { markDirty(target) + updatedRowKeys.push(String(target._row_key || '').trim()) updated++ } } if (matched === 0) { Notify.create({ type: 'warning', message: 'CSV icindeki satirlar ekrandaki kayitlarla eslesmedi' }) + csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: 'CSV icindeki satirlar ekrandaki kayitlarla eslesmedi.' } return } - Notify.create({ - type: 'positive', - message: `CSV yuklendi. Islenen: ${matrix.length - 1}, eslesen: ${matched}, guncellenen: ${updated}, atlanan: ${skipped}` + // Ensure: CSV'den degisen satirlar hem dirty hem de "Kaydet" secimi (checkbox) olarak isaretlensin. + // BazΔ± render edge-case'lerinde sadece sayac artip checkbox guncellenmiyor gibi gorunebiliyor; + // burada selection map'i explicit guncelleyip senkronu garanti ediyoruz. + if (updatedRowKeys.length > 0) { + const next = { ...(selectedKeyMap.value || {}) } + for (const key of updatedRowKeys) { + if (!key) continue + next[key] = true + } + selectedKeyMap.value = next + } + + const summary = `CSV yuklendi. Islenen: ${matrix.length - 1}, eslesen: ${matched}, guncellenen: ${updated}, atlanan: ${skipped}` + if (updated === 0) { + Notify.create({ type: 'warning', message: summary }) + csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: summary } + } else { + Notify.create({ type: 'positive', message: summary }) + csvImportStatus.value = { type: 'positive', at: new Date().toISOString(), message: summary } + } + + console.info('[pricing-rules][ui] csv-import:done', { + duration_ms: Date.now() - startedAt, + processed: matrix.length - 1, + matched, + updated, + skipped }) } catch (err) { Notify.create({ type: 'negative', message: err?.message || 'CSV okunamadi' }) + csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: err?.message || 'CSV okunamadi' } } finally { if (input) input.value = '' } @@ -953,6 +1094,12 @@ function copySelectedToSelected () { const target = rows.value.find(row => row._row_key === keys[i]) if (!target) continue target.is_active = Boolean(source.is_active) + target.calc_enabled = Boolean(source.calc_enabled) + target.publish_postgres = Boolean(source.publish_postgres) + target.publish_nebim = Boolean(source.publish_nebim) + target.try_retail_mode = String(source.try_retail_mode || 'STEP') + target.usd_retail_mode = String(source.usd_retail_mode || 'STEP') + target.eur_retail_mode = String(source.eur_retail_mode || 'STEP') for (const field of numericFields) { target[field] = source[field] } @@ -1056,8 +1203,7 @@ async function refreshRows () { } clearAllFilters() - selectedKeyMap.value = {} - copySelectedKeys.value = [] + clearSelections() await loadRows() } @@ -1067,6 +1213,7 @@ async function loadRows () { emptyRetryTimer = null } loading.value = true + let ok = false try { const res = await api.request({ method: 'GET', @@ -1074,16 +1221,17 @@ async function loadRows () { timeout: 180000 }) rows.value = (Array.isArray(res?.data) ? res.data : []).map(normalizeWorksheetRow) - selectedKeyMap.value = {} - copySelectedKeys.value = [] + clearSelections() if (rows.value.length === 0) { emptyRetryTimer = setTimeout(loadRows, 10000) } + ok = true } catch (err) { Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kural kombinasyonlari alinamadi' }) } finally { loading.value = false } + return ok } async function saveSelected () { @@ -1091,27 +1239,124 @@ async function saveSelected () { if (dirty.length === 0) return saving.value = true try { - const payload = { - items: dirty.map(row => { - const item = { - id: row.id, - pricing_parameter_id: row.pricing_parameter_id, - is_active: Boolean(row.is_active) - } - for (const key of numericFields) item[key] = finiteNumber(row[key], 0) - return item - }) - } - await api.request({ - method: 'POST', - url: '/pricing/pricing-rules/bulk-save', - data: payload, - timeout: 180000 + const startedAt = Date.now() + console.info('[pricing-rules][ui] saveSelected:start', { + at: new Date(startedAt).toISOString(), + dirty_count: dirty.length }) + + const buildPayload = (list) => { + return { + items: list.map(row => { + const item = { + id: row.id, + pricing_parameter_id: row.pricing_parameter_id, + calc_enabled: Boolean(row.calc_enabled), + publish_postgres: Boolean(row.publish_postgres), + publish_nebim: Boolean(row.publish_nebim), + is_active: Boolean(row.is_active), + try_retail_mode: String(row.try_retail_mode || 'STEP'), + usd_retail_mode: String(row.usd_retail_mode || 'STEP'), + eur_retail_mode: String(row.eur_retail_mode || 'STEP') + } + for (const key of numericFields) item[key] = finiteNumber(row[key], 0) + return item + }) + } + } + + const makeTraceId = () => `ui-${Date.now()}-${Math.random().toString(16).slice(2)}` + const isTimeoutLikeError = (e) => { + const status = e?.response?.status || null + // With axios timeout disabled for bulk-save, treat only real upstream/proxy timeouts as retry-able. + return status === 504 + } + + let savedTotal = 0 + const failedKeys = [] + + const postBulkSave = async (list) => { + const traceId = makeTraceId() + const payload = buildPayload(list) + await api.request({ + method: 'POST', + url: '/pricing/pricing-rules/bulk-save', + data: payload, + // Disable axios timeout here: backend may legitimately run for several minutes on the first write after a full truncate/import. + // Any upstream/proxy timeout will surface as 504 anyway. + timeout: 0, + headers: { 'X-Trace-ID': traceId } + }) + return traceId + } + + // Prefer single request (fast path). Fallback to bisection only on proxy/timeout errors. + try { + const traceId = await postBulkSave(dirty) + savedTotal = dirty.length + console.info('[pricing-rules][ui] saveSelected:one-shot:done', { trace_id: traceId, total: dirty.length }) + } catch (e) { + if (!isTimeoutLikeError(e)) throw e + + const initialChunkSize = 50 + const queue = [] + for (let offset = 0; offset < dirty.length; offset += initialChunkSize) { + queue.push(dirty.slice(offset, offset + initialChunkSize)) + } + + while (queue.length > 0) { + const batch = queue.shift() + if (!batch || batch.length === 0) continue + + console.info('[pricing-rules][ui] saveSelected:batch:start', { + batch_size: batch.length, + saved_total: savedTotal, + total: dirty.length + }) + + try { + const traceId = await postBulkSave(batch) + savedTotal += batch.length + Notify.create({ type: 'positive', message: `Kaydedildi: ${savedTotal} / ${dirty.length}` }) + console.info('[pricing-rules][ui] saveSelected:batch:done', { + trace_id: traceId, + batch_size: batch.length, + saved_total: savedTotal, + total: dirty.length + }) + } catch (err2) { + if (isTimeoutLikeError(err2) && batch.length > 1) { + const mid = Math.ceil(batch.length / 2) + queue.unshift(batch.slice(mid)) + queue.unshift(batch.slice(0, mid)) + continue + } + throw err2 + } + } + } + + const reloaded = await loadRows() + if (!reloaded) { + Notify.create({ + type: 'warning', + message: 'Kaydetme tamamlandi, ancak liste yenilenemedi. Sayfayi yenileyip (F5) kontrol edin.' + }) + return + } Notify.create({ type: 'positive', message: `Kaydedildi: ${dirty.length}` }) - await loadRows() + csvImportStatus.value = null + console.info('[pricing-rules][ui] saveSelected:done', { + duration_ms: Date.now() - startedAt, + dirty_count: dirty.length, + reloaded: true + }) } catch (err) { Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kurallar kaydedilemedi' }) + console.error('[pricing-rules][ui] saveSelected:error', { + status: err?.response?.status || null, + message: err?.response?.data || err?.message || 'Kurallar kaydedilemedi' + }) } finally { saving.value = false } @@ -1119,8 +1364,7 @@ async function saveSelected () { function resetTransientState () { rows.value = [] - selectedKeyMap.value = {} - copySelectedKeys.value = [] + clearSelections() } onMounted(refreshRows) @@ -1133,10 +1377,11 @@ onBeforeUnmount(() => {