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 ), -- Prefer PG's authoritative variant dimension table (mmitem_dim). Fall back to cache table if needed. dims_mmitem_dim AS ( SELECT norm.product_code AS product_code, md.val1::bigint AS dim1, CASE WHEN md.val3 IS NULL OR md.val3 = 0 THEN NULL ELSE md.val3::bigint END AS dim3 FROM norm JOIN mmitem mm ON mm.code = norm.product_code JOIN mmitem_dim md ON md.mmitem_id = mm.id AND COALESCE(md.is_active, TRUE) = TRUE WHERE md.val1 IS NOT NULL AND md.val1 > 0 GROUP BY norm.product_code, md.val1, md.val2 ), dims_cache_table AS ( SELECT NULLIF(BTRIM(c.product_code), '') AS product_code, c.dim1::bigint AS dim1, c.dim3::bigint AS dim3 FROM mk_mmitem_dim_combo c JOIN norm ON norm.product_code = c.product_code WHERE c.dim1 IS NOT NULL ), dims_cache AS ( SELECT product_code, dim1, dim3 FROM dims_mmitem_dim UNION SELECT product_code, dim1, dim3 FROM dims_cache_table ), 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 }