Files
bssapp/svc/routes/pricing_rules.go
2026-06-04 18:08:18 +03:00

628 lines
19 KiB
Go

package routes
import (
"bssapp-backend/queries"
"bssapp-backend/utils"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// Step-1/2 scope (distinct+cascade) comes from the PostgreSQL parameter cache.
// For now we implement:
// - Postgres tables (bootstrap)
// - List/Save rules (bulk)
// - Options endpoint for cascade (mk_urunpricingprmtr)
type PricingRuleBulkSavePayload struct {
Items []queries.PricingRuleSaveItem `json:"items"`
}
type PricingRuleImportItem struct {
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"`
Marka string `json:"marka"`
BrandCode string `json:"brand_code"`
BrandGroupSec string `json:"brand_group"`
IsActive bool `json:"is_active"`
TryBase float64 `json:"try_base"`
Try1 float64 `json:"try1"`
Try2 float64 `json:"try2"`
Try3 float64 `json:"try3"`
Try4 float64 `json:"try4"`
Try5 float64 `json:"try5"`
Try6 float64 `json:"try6"`
TryWholesaleStep float64 `json:"try_wholesale_step"`
TryRetailStep float64 `json:"try_retail_step"`
UsdBase float64 `json:"usd_base"`
Usd1 float64 `json:"usd1"`
Usd2 float64 `json:"usd2"`
Usd3 float64 `json:"usd3"`
Usd4 float64 `json:"usd4"`
Usd5 float64 `json:"usd5"`
Usd6 float64 `json:"usd6"`
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
UsdRetailStep float64 `json:"usd_retail_step"`
EurBase float64 `json:"eur_base"`
Eur1 float64 `json:"eur1"`
Eur2 float64 `json:"eur2"`
Eur3 float64 `json:"eur3"`
Eur4 float64 `json:"eur4"`
Eur5 float64 `json:"eur5"`
Eur6 float64 `json:"eur6"`
EurWholesaleStep float64 `json:"eur_wholesale_step"`
EurRetailStep float64 `json:"eur_retail_step"`
}
type PricingRuleImportPayload struct {
Items []PricingRuleImportItem `json:"items"`
}
type PricingRuleImportResult struct {
Success bool `json:"success"`
Processed int `json:"processed"`
Matched int `json:"matched"`
Skipped int `json:"skipped"`
Updated int `json:"updated"`
ActivatedScopeCount int `json:"activated_scope_count"`
ErrorCount int `json:"error_count"`
}
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")
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
rows, err := queries.ListPricingRules(ctx, pg)
if err != nil {
http.Error(w, "pricing rules list error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(rows)
}
}
// Very small “bulk upsert” for step-1/2: we only need to persist the multipliers+roundings for now.
// Rules are identified by UUID; new rows can be created via empty id (server generates).
func SavePricingRulesBulkHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var payload PricingRuleBulkSavePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
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 _, 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 {
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)
return
}
if id != "" {
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})
}
}
func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var payload PricingRuleImportPayload
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(PricingRuleImportResult{Success: true})
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
matched := 0
skipped := 0
for _, raw := range payload.Items {
if raw.TryWholesaleStep < 0 || raw.TryRetailStep < 0 || raw.UsdWholesaleStep < 0 || raw.UsdRetailStep < 0 || raw.EurWholesaleStep < 0 || raw.EurRetailStep < 0 {
http.Error(w, "invalid rounding step", http.StatusBadRequest)
return
}
pricingParameterID, err := queries.FindActivePricingParameterByScope(ctx, tx, queries.PricingParameterRowForImport(
raw.AskiliYan,
raw.Kategori,
raw.UrunIlkGrubu,
raw.UrunAnaGrubu,
raw.UrunAltGrubu,
raw.Icerik,
raw.Marka,
raw.BrandCode,
raw.BrandGroupSec,
))
if err == sql.ErrNoRows {
skipped++
continue
}
if err != nil {
http.Error(w, "pricing parameter resolve error", http.StatusInternalServerError)
return
}
matched++
_, err = queries.UpsertPricingRule(ctx, tx, queries.PricingRuleSaveItem{
PricingParameterID: pricingParameterID,
IsActive: raw.IsActive,
TryBase: raw.TryBase,
Try1: raw.Try1,
Try2: raw.Try2,
Try3: raw.Try3,
Try4: raw.Try4,
Try5: raw.Try5,
Try6: raw.Try6,
TryWholesaleStep: raw.TryWholesaleStep,
TryRetailStep: raw.TryRetailStep,
UsdBase: raw.UsdBase,
Usd1: raw.Usd1,
Usd2: raw.Usd2,
Usd3: raw.Usd3,
Usd4: raw.Usd4,
Usd5: raw.Usd5,
Usd6: raw.Usd6,
UsdWholesaleStep: raw.UsdWholesaleStep,
UsdRetailStep: raw.UsdRetailStep,
EurBase: raw.EurBase,
Eur1: raw.Eur1,
Eur2: raw.Eur2,
Eur3: raw.Eur3,
Eur4: raw.Eur4,
Eur5: raw.Eur5,
Eur6: raw.Eur6,
EurWholesaleStep: raw.EurWholesaleStep,
EurRetailStep: raw.EurRetailStep,
})
if err != nil {
http.Error(w, "pricing rule import 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(PricingRuleImportResult{
Success: true,
Processed: len(payload.Items),
Matched: matched,
Skipped: skipped,
Updated: updated,
ActivatedScopeCount: 0,
ErrorCount: 0,
})
}
}
func GetPricingRuleOptionsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
field := strings.TrimSpace(r.URL.Query().Get("field"))
if field == "" {
http.Error(w, "missing field", http.StatusBadRequest)
return
}
limit := 500
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 5000 {
limit = n
}
}
f := pricingRuleFiltersFromRequest(r)
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
opts, err := queries.ListPricingParameterDistinctOptions(ctx, pg, field, f, limit)
if err != nil {
http.Error(w, "options lookup error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"field": field,
"options": opts,
})
}
}
func GetPricingParameterRulesHandler(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)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
rows, err := queries.ListPricingParameterRules(ctx, pg, pricingRuleFiltersFromRequest(r))
if err != nil {
http.Error(w, "pricing parameter rules list error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(rows)
}
}
func ExportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
rows, err := queries.ListPricingParameterRules(ctx, pg, pricingRuleFiltersFromRequest(r))
if err != nil {
http.Error(w, "pricing parameter rules export error", http.StatusInternalServerError)
return
}
rows = filterPricingRuleExportRows(rows, r)
sortPricingRuleExportRows(rows, strings.TrimSpace(r.URL.Query().Get("sort_by")), strings.TrimSpace(r.URL.Query().Get("desc")) != "0")
filename := fmt.Sprintf("pricing_rules_all_%s.csv", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
_, _ = w.Write([]byte("\uFEFF"))
_, _ = w.Write([]byte(buildPricingRuleCSV(rows)))
}
}
func pricingRuleFiltersFromRequest(r *http.Request) queries.PricingRuleOptionFilters {
return queries.PricingRuleOptionFilters{
AskiliYan: splitCSV(r.URL.Query().Get("askili_yan")),
Kategori: splitCSV(r.URL.Query().Get("kategori")),
UrunIlkGrubu: splitCSV(r.URL.Query().Get("urun_ilk_grubu")),
UrunAnaGrubu: splitCSV(r.URL.Query().Get("urun_ana_grubu")),
UrunAltGrubu: splitCSV(r.URL.Query().Get("urun_alt_grubu")),
Icerik: splitCSV(r.URL.Query().Get("icerik")),
Marka: splitCSV(r.URL.Query().Get("marka")),
BrandCode: splitCSV(r.URL.Query().Get("brand_code")),
BrandGroupSec: splitCSV(r.URL.Query().Get("brand_group")),
}
}
func filterPricingRuleExportRows(rows []queries.PricingParameterRuleRow, r *http.Request) []queries.PricingParameterRuleRow {
rangeFilter := func(prefix string) (*float64, *float64) {
parse := func(raw string) *float64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
v, err := strconv.ParseFloat(strings.ReplaceAll(raw, ",", "."), 64)
if err != nil {
return nil
}
return &v
}
return parse(r.URL.Query().Get(prefix + "_min")), parse(r.URL.Query().Get(prefix + "_max"))
}
fields := []string{
"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",
}
minMap := map[string]*float64{}
maxMap := map[string]*float64{}
for _, field := range fields {
minMap[field], maxMap[field] = rangeFilter(field)
}
out := make([]queries.PricingParameterRuleRow, 0, len(rows))
for _, row := range rows {
ok := true
for _, field := range fields {
value := pricingRuleNumericValue(row, field)
if minMap[field] != nil && value < *minMap[field] {
ok = false
break
}
if maxMap[field] != nil && value > *maxMap[field] {
ok = false
break
}
}
if ok {
out = append(out, row)
}
}
return out
}
func pricingRuleNumericValue(row queries.PricingParameterRuleRow, field string) float64 {
if row.Rule == nil {
return 0
}
switch field {
case "try_base":
return row.Rule.TryBase
case "try1":
return row.Rule.Try1
case "try2":
return row.Rule.Try2
case "try3":
return row.Rule.Try3
case "try4":
return row.Rule.Try4
case "try5":
return row.Rule.Try5
case "try6":
return row.Rule.Try6
case "try_wholesale_step":
return row.Rule.TryWholesaleStep
case "try_retail_step":
return row.Rule.TryRetailStep
case "usd_base":
return row.Rule.UsdBase
case "usd1":
return row.Rule.Usd1
case "usd2":
return row.Rule.Usd2
case "usd3":
return row.Rule.Usd3
case "usd4":
return row.Rule.Usd4
case "usd5":
return row.Rule.Usd5
case "usd6":
return row.Rule.Usd6
case "usd_wholesale_step":
return row.Rule.UsdWholesaleStep
case "usd_retail_step":
return row.Rule.UsdRetailStep
case "eur_base":
return row.Rule.EurBase
case "eur1":
return row.Rule.Eur1
case "eur2":
return row.Rule.Eur2
case "eur3":
return row.Rule.Eur3
case "eur4":
return row.Rule.Eur4
case "eur5":
return row.Rule.Eur5
case "eur6":
return row.Rule.Eur6
case "eur_wholesale_step":
return row.Rule.EurWholesaleStep
case "eur_retail_step":
return row.Rule.EurRetailStep
default:
return 0
}
}
func sortPricingRuleExportRows(rows []queries.PricingParameterRuleRow, sortBy string, desc bool) {
sortBy = strings.TrimSpace(sortBy)
if sortBy == "" {
return
}
sort.SliceStable(rows, func(i, j int) bool {
li, lj := rows[i], rows[j]
switch sortBy {
case "has_rule":
if desc {
return boolRank(li.HasRule) > boolRank(lj.HasRule)
}
return boolRank(li.HasRule) < boolRank(lj.HasRule)
case "is_active":
liActive, ljActive := false, false
if li.Rule != nil {
liActive = li.Rule.IsActive
}
if lj.Rule != nil {
ljActive = lj.Rule.IsActive
}
if desc {
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":
vi := pricingRuleStringValue(li, sortBy)
vj := pricingRuleStringValue(lj, sortBy)
if desc {
return strings.Compare(vi, vj) > 0
}
return strings.Compare(vi, vj) < 0
default:
vi := pricingRuleNumericValue(li, sortBy)
vj := pricingRuleNumericValue(lj, sortBy)
if desc {
return vi > vj
}
return vi < vj
}
})
}
func boolRank(v bool) int {
if v {
return 1
}
return 0
}
func pricingRuleStringValue(row queries.PricingParameterRuleRow, field string) string {
switch field {
case "askili_yan":
return row.AskiliYan
case "kategori":
return row.Kategori
case "urun_ilk_grubu":
return row.UrunIlkGrubu
case "urun_ana_grubu":
return row.UrunAnaGrubu
case "urun_alt_grubu":
return row.UrunAltGrubu
case "icerik":
return row.Icerik
case "marka":
return row.Marka
case "brand_code":
return row.BrandCode
case "brand_group":
return row.BrandGroupSec
default:
return ""
}
}
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",
}
var b strings.Builder
for i, h := range headers {
b.WriteString(csvEscapeValue(h))
if i == len(headers)-1 {
b.WriteString("\n")
} else {
b.WriteString(";")
}
}
for _, row := range rows {
active := "Pasif"
if row.Rule == nil || row.Rule.IsActive {
active = "Aktif"
}
values := []string{
map[bool]string{true: "Tanimli", false: "Yeni"}[row.HasRule],
active,
row.AskiliYan,
row.Kategori,
row.UrunIlkGrubu,
row.UrunAnaGrubu,
row.UrunAltGrubu,
row.Icerik,
row.Marka,
csvExcelTextValue(row.BrandCode),
row.BrandGroupSec,
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_wholesale_step")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_retail_step")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_base")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try1")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try2")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try3")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try4")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try5")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try6")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_wholesale_step")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_retail_step")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_base")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd1")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd2")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd3")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd4")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd5")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd6")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_wholesale_step")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_retail_step")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_base")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur1")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur2")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur3")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur4")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur5")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur6")),
}
for i, value := range values {
b.WriteString(csvEscapeValue(value))
if i == len(values)-1 {
b.WriteString("\n")
} else {
b.WriteString(";")
}
}
}
return b.String()
}
func csvEscapeValue(value string) string {
text := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(value), "\r", " "), "\n", " ")
if strings.Contains(text, ";") || strings.Contains(text, "\"") {
text = `"` + strings.ReplaceAll(text, `"`, `""`) + `"`
}
return text
}
func csvExcelTextValue(value string) string {
text := strings.TrimSpace(value)
if text == "" {
return ""
}
return `="` + strings.ReplaceAll(text, `"`, `""`) + `"`
}
func splitCSV(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}