Merge remote-tracking branch 'origin/master'

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

View File

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