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