Files
bssapp/svc/queries/pricing_calc_engine.go
2026-06-17 21:57:02 +03:00

680 lines
20 KiB
Go

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