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

@@ -22,6 +22,7 @@ type BrandGroupOption struct {
Code string `json:"code"`
Title string `json:"title"`
Description string `json:"description"`
AnchorMode string `json:"anchor_mode"`
}
func EnsureBrandClassificationTables(pg *sql.DB) error {
@@ -41,10 +42,15 @@ CREATE TABLE IF NOT EXISTS mk_brandgrp (
code TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
anchor_mode TEXT NOT NULL DEFAULT 'USD',
sort_order SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`ALTER TABLE mk_brandgrp ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE mk_brandgrp ADD COLUMN IF NOT EXISTS anchor_mode TEXT NOT NULL DEFAULT 'USD'`,
`UPDATE mk_brandgrp SET anchor_mode='USD' WHERE COALESCE(NULLIF(BTRIM(anchor_mode), ''), '') = ''`,
`ALTER TABLE mk_brandgrp DROP CONSTRAINT IF EXISTS ck_mk_brandgrp_anchor_mode`,
`ALTER TABLE mk_brandgrp ADD CONSTRAINT ck_mk_brandgrp_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))`,
`
INSERT INTO mk_brandgrp (id, code, title, description, sort_order)
VALUES
@@ -74,7 +80,7 @@ CREATE TABLE IF NOT EXISTS mk_brandgrpmatch (
}
func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error) {
rows, err := pg.QueryContext(ctx, `SELECT id, code, title, description FROM mk_brandgrp ORDER BY sort_order, id`)
rows, err := pg.QueryContext(ctx, `SELECT id, code, title, description, anchor_mode FROM mk_brandgrp ORDER BY sort_order, id`)
if err != nil {
return nil, err
}
@@ -82,17 +88,57 @@ func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error
out := make([]BrandGroupOption, 0, 8)
for rows.Next() {
var o BrandGroupOption
if err := rows.Scan(&o.ID, &o.Code, &o.Title, &o.Description); err != nil {
if err := rows.Scan(&o.ID, &o.Code, &o.Title, &o.Description, &o.AnchorMode); err != nil {
return nil, err
}
o.Code = strings.TrimSpace(o.Code)
o.Title = strings.TrimSpace(o.Title)
o.Description = strings.TrimSpace(o.Description)
o.AnchorMode = strings.ToUpper(strings.TrimSpace(o.AnchorMode))
if o.AnchorMode == "" {
o.AnchorMode = "USD"
}
out = append(out, o)
}
return out, rows.Err()
}
func SetBrandGroupAnchorMode(ctx context.Context, tx *sql.Tx, grpID int, anchorMode string) error {
anchorMode = strings.ToUpper(strings.TrimSpace(anchorMode))
if anchorMode == "" {
anchorMode = "USD"
}
_, err := tx.ExecContext(ctx, `
UPDATE mk_brandgrp
SET anchor_mode=$2
WHERE id=$1
`, grpID, anchorMode)
return err
}
func SyncPricingRuleAnchorModesByGroup(ctx context.Context, tx *sql.Tx, grpID int, anchorMode string) error {
anchorMode = strings.ToUpper(strings.TrimSpace(anchorMode))
if anchorMode == "" {
anchorMode = "USD"
}
_, err := tx.ExecContext(ctx, `
UPDATE mk_pricing_rule r
SET anchor_mode=$2,
updated_at=now()
WHERE EXISTS (
SELECT 1
FROM mk_brandgrp g
JOIN LATERAL unnest(r.brand_group) bg(value) ON TRUE
WHERE g.id=$1
AND (
UPPER(BTRIM(bg.value)) = UPPER(BTRIM(g.code))
OR UPPER(BTRIM(bg.value)) = UPPER(BTRIM(g.title))
)
)
`, grpID, anchorMode)
return err
}
func ListBrandsWithGroups(ctx context.Context, pg *sql.DB, q string, limit int) ([]BrandRow, error) {
if limit <= 0 {
limit = 5000

View File

@@ -0,0 +1,679 @@
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[:])
}

View File

@@ -0,0 +1,188 @@
package queries
import (
"database/sql"
"fmt"
)
func EnsurePricingCalcInfraTables(pg *sql.DB) error {
stmts := []string{
`
CREATE TABLE IF NOT EXISTS mk_fx_rate_cache (
rate_date DATE PRIMARY KEY,
usd_try NUMERIC(18,6) NOT NULL DEFAULT 0,
eur_try NUMERIC(18,6) NOT NULL DEFAULT 0,
usd_eur NUMERIC(18,6) NOT NULL DEFAULT 0,
source_system TEXT NOT NULL DEFAULT 'MSSQL',
source_updated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_fx_rate_cache_updated_at ON mk_fx_rate_cache (updated_at DESC)`,
`
CREATE TABLE IF NOT EXISTS mk_price_snapshot (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_code TEXT NOT NULL,
pricing_parameter_id BIGINT REFERENCES mk_urunpricingprmtr(id) ON DELETE CASCADE,
rule_id UUID REFERENCES mk_pricing_rule(id) ON DELETE SET NULL,
strategy_code TEXT NOT NULL DEFAULT 'CORE',
anchor_mode TEXT NOT NULL DEFAULT 'USD',
fx_date DATE NOT NULL,
cost_date DATE,
base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0,
base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
try1 NUMERIC(18,6) NOT NULL DEFAULT 0,
try2 NUMERIC(18,6) NOT NULL DEFAULT 0,
try3 NUMERIC(18,6) NOT NULL DEFAULT 0,
try4 NUMERIC(18,6) NOT NULL DEFAULT 0,
try5 NUMERIC(18,6) NOT NULL DEFAULT 0,
try6 NUMERIC(18,6) NOT NULL DEFAULT 0,
usd1 NUMERIC(18,6) NOT NULL DEFAULT 0,
usd2 NUMERIC(18,6) NOT NULL DEFAULT 0,
usd3 NUMERIC(18,6) NOT NULL DEFAULT 0,
usd4 NUMERIC(18,6) NOT NULL DEFAULT 0,
usd5 NUMERIC(18,6) NOT NULL DEFAULT 0,
usd6 NUMERIC(18,6) NOT NULL DEFAULT 0,
eur1 NUMERIC(18,6) NOT NULL DEFAULT 0,
eur2 NUMERIC(18,6) NOT NULL DEFAULT 0,
eur3 NUMERIC(18,6) NOT NULL DEFAULT 0,
eur4 NUMERIC(18,6) NOT NULL DEFAULT 0,
eur5 NUMERIC(18,6) NOT NULL DEFAULT 0,
eur6 NUMERIC(18,6) NOT NULL DEFAULT 0,
calc_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_mk_price_snapshot_product_scope UNIQUE (product_code, pricing_parameter_id),
CONSTRAINT ck_mk_price_snapshot_strategy_code CHECK (strategy_code IN ('CORE','PREMIUM','SARTORIAL')),
CONSTRAINT ck_mk_price_snapshot_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_price_snapshot_rule ON mk_price_snapshot (rule_id)`,
`CREATE INDEX IF NOT EXISTS ix_mk_price_snapshot_updated_at ON mk_price_snapshot (updated_at DESC)`,
`
CREATE TABLE IF NOT EXISTS mk_price_target_map_pg (
id BIGSERIAL PRIMARY KEY,
currency TEXT NOT NULL,
level_no SMALLINT NOT NULL,
sdprcgrp_id INTEGER,
description TEXT NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_mk_price_target_map_pg UNIQUE (currency, level_no),
CONSTRAINT ck_mk_price_target_map_pg_currency CHECK (currency IN ('TRY','USD','EUR')),
CONSTRAINT ck_mk_price_target_map_pg_level_no CHECK (level_no BETWEEN 1 AND 6)
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_price_target_map_pg_active ON mk_price_target_map_pg (is_active, currency, level_no)`,
`
CREATE TABLE IF NOT EXISTS mk_price_target_map_nebim (
id BIGSERIAL PRIMARY KEY,
currency TEXT NOT NULL,
level_no SMALLINT NOT NULL,
price_group_code TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_mk_price_target_map_nebim UNIQUE (currency, level_no),
CONSTRAINT ck_mk_price_target_map_nebim_currency CHECK (currency IN ('TRY','USD','EUR')),
CONSTRAINT ck_mk_price_target_map_nebim_level_no CHECK (level_no BETWEEN 1 AND 6)
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_price_target_map_nebim_active ON mk_price_target_map_nebim (is_active, currency, level_no)`,
`
CREATE TABLE IF NOT EXISTS mk_price_recalc_queue (
id BIGSERIAL PRIMARY KEY,
product_code TEXT NOT NULL,
pricing_parameter_id BIGINT REFERENCES mk_urunpricingprmtr(id) ON DELETE SET NULL,
reason TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
attempts SMALLINT NOT NULL DEFAULT 0,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
last_error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ck_mk_price_recalc_queue_status CHECK (status IN ('pending','processing','done','failed'))
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_price_recalc_queue_status ON mk_price_recalc_queue (status, available_at, queued_at)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uq_mk_price_recalc_queue_pending ON mk_price_recalc_queue (product_code, COALESCE(pricing_parameter_id, 0)) WHERE status IN ('pending','processing')`,
`
CREATE TABLE IF NOT EXISTS mk_mmitem_dim_combo (
product_code TEXT NOT NULL,
dim1 INTEGER NOT NULL,
dim3 INTEGER,
dim3_key INTEGER GENERATED ALWAYS AS (COALESCE(dim3, 0)) STORED,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT pk_mk_mmitem_dim_combo PRIMARY KEY (product_code, dim1, dim3_key)
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_mmitem_dim_combo_product ON mk_mmitem_dim_combo (product_code, updated_at DESC)`,
`
CREATE TABLE IF NOT EXISTS mk_dim_token_map (
dim_column TEXT NOT NULL, -- dimval1 or dimval3
token TEXT NOT NULL, -- normalized token (e.g. "001", "82", etc.)
dim_id INTEGER NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT pk_mk_dim_token_map PRIMARY KEY (dim_column, token)
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_dim_token_map_updated ON mk_dim_token_map (updated_at DESC)`,
}
for _, stmt := range stmts {
if _, err := pg.Exec(stmt); err != nil {
return err
}
}
if err := seedPricingTargetMapRows(pg, "mk_price_target_map_pg", "sdprcgrp_id"); err != nil {
return err
}
if err := seedPricingTargetMapRows(pg, "mk_price_target_map_nebim", "price_group_code"); err != nil {
return err
}
// Repair invalid/missing pg target mappings after manual edits or table resets.
// sdprcgrp_id is expected to be 1..6 in this installation.
if _, err := pg.Exec(`
UPDATE mk_price_target_map_pg
SET sdprcgrp_id = level_no,
updated_at = now()
WHERE is_active = TRUE
AND (sdprcgrp_id IS NULL OR sdprcgrp_id NOT BETWEEN 1 AND 6)
`); err != nil {
return err
}
return nil
}
func seedPricingTargetMapRows(pg *sql.DB, tableName string, valueColumn string) error {
currencies := []string{"TRY", "USD", "EUR"}
for _, currency := range currencies {
for level := 1; level <= 6; level++ {
stmt := fmt.Sprintf(`
INSERT INTO %s (currency, level_no, %s, description, is_active, created_at, updated_at)
VALUES ($1, $2, NULL, '', TRUE, now(), now())
ON CONFLICT (currency, level_no) DO NOTHING
`, tableName, valueColumn)
// PG targets: default sdprcgrp_id = level_no (1..6). This keeps sdprc writes valid after resets.
if tableName == "mk_price_target_map_pg" && valueColumn == "sdprcgrp_id" {
stmt = fmt.Sprintf(`
INSERT INTO %s (currency, level_no, %s, description, is_active, created_at, updated_at)
VALUES ($1, $2, $2, '', TRUE, now(), now())
ON CONFLICT (currency, level_no) DO NOTHING
`, tableName, valueColumn)
}
if valueColumn == "price_group_code" {
stmt = fmt.Sprintf(`
INSERT INTO %s (currency, level_no, %s, description, is_active, created_at, updated_at)
VALUES ($1, $2, '', '', TRUE, now(), now())
ON CONFLICT (currency, level_no) DO NOTHING
`, tableName, valueColumn)
}
if _, err := pg.Exec(stmt, currency, level); err != nil {
return err
}
}
}
return nil
}

View File

@@ -196,7 +196,6 @@ SELECT
icerik, marka, brand_code, brand_group_sec, scope_key
FROM mk_urunpricingprmtr
WHERE id=$1
AND is_active=TRUE
`, pricingParameterID).Scan(
&p.AskiliYan,
&p.Kategori,
@@ -441,6 +440,12 @@ WHERE ProductAtt42 IN ('SERI', 'AKSESUAR')
}
defer tx.Rollback()
// Serialize writes touching mk_urunpricingprmtr/mk_pricing_rule/mk_pricex/mk_priceroll
// to avoid deadlocks with bulk-save/import flows.
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil {
return out, err
}
if _, err := tx.ExecContext(ctx, `
CREATE TEMP TABLE tmp_urunpricingprmtr_sync (
askili_yan TEXT NOT NULL,
@@ -714,6 +719,17 @@ SELECT
p.brand_code,
p.brand_group_sec,
COALESCE(r.id::text, ''),
COALESCE(
r.strategy_code,
CASE
WHEN UPPER(BTRIM(p.brand_group_sec)) IN ('CORE','PREMIUM','SARTORIAL') THEN UPPER(BTRIM(p.brand_group_sec))
ELSE 'CORE'
END
),
COALESCE(r.anchor_mode, bg.anchor_mode, 'USD'),
COALESCE(r.calc_enabled, TRUE),
COALESCE(r.publish_postgres, TRUE),
COALESCE(r.publish_nebim, TRUE),
COALESCE(r.is_active, TRUE),
COALESCE(tx.base_mult, 0)::float8,
@@ -725,6 +741,7 @@ SELECT
COALESCE(tx.m6, 0)::float8,
COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8,
COALESCE(NULLIF(tr.retail_step, 0), tr.step, 0)::float8,
COALESCE(NULLIF(BTRIM(tr.retail_mode), ''), 'STEP'),
COALESCE(ux.base_mult, 0)::float8,
COALESCE(ux.m1, 0)::float8,
@@ -735,6 +752,7 @@ SELECT
COALESCE(ux.m6, 0)::float8,
COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8,
COALESCE(NULLIF(ur.retail_step, 0), ur.step, 0)::float8,
COALESCE(NULLIF(BTRIM(ur.retail_mode), ''), 'STEP'),
COALESCE(ex.base_mult, 0)::float8,
COALESCE(ex.m1, 0)::float8,
@@ -744,7 +762,8 @@ SELECT
COALESCE(ex.m5, 0)::float8,
COALESCE(ex.m6, 0)::float8,
COALESCE(NULLIF(er.wholesale_step, 0), er.step, 0)::float8,
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8,
COALESCE(NULLIF(BTRIM(er.retail_mode), ''), 'STEP')
FROM mk_urunpricingprmtr p
LEFT JOIN LATERAL (
SELECT latest_rule.*
@@ -753,6 +772,14 @@ LEFT JOIN LATERAL (
ORDER BY latest_rule.created_at DESC, latest_rule.updated_at DESC, latest_rule.id DESC
LIMIT 1
) r ON TRUE
LEFT JOIN LATERAL (
SELECT g.anchor_mode
FROM mk_brandgrp g
WHERE UPPER(BTRIM(g.code)) = UPPER(BTRIM(p.brand_group_sec))
OR UPPER(BTRIM(g.title)) = UPPER(BTRIM(p.brand_group_sec))
ORDER BY g.id
LIMIT 1
) bg ON TRUE
LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY'
LEFT JOIN mk_pricex ux ON ux.rule_id = r.id AND ux.currency='USD'
LEFT JOIN mk_pricex ex ON ex.rule_id = r.id AND ex.currency='EUR'
@@ -790,13 +817,21 @@ ORDER BY
&item.BrandCode,
&item.BrandGroupSec,
&rule.ID,
&rule.StrategyCode,
&rule.AnchorMode,
&rule.CalcEnabled,
&rule.PublishPostgres,
&rule.PublishNebim,
&rule.IsActive,
&rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryWholesaleStep, &rule.TryRetailStep,
&rule.UsdBase, &rule.Usd1, &rule.Usd2, &rule.Usd3, &rule.Usd4, &rule.Usd5, &rule.Usd6, &rule.UsdWholesaleStep, &rule.UsdRetailStep,
&rule.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurWholesaleStep, &rule.EurRetailStep,
&rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryWholesaleStep, &rule.TryRetailStep, &rule.TryRetailMode,
&rule.UsdBase, &rule.Usd1, &rule.Usd2, &rule.Usd3, &rule.Usd4, &rule.Usd5, &rule.Usd6, &rule.UsdWholesaleStep, &rule.UsdRetailStep, &rule.UsdRetailMode,
&rule.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurWholesaleStep, &rule.EurRetailStep, &rule.EurRetailMode,
); err != nil {
return nil, err
}
rule.TryRetailMode = normalizeRetailMode(rule.TryRetailMode)
rule.UsdRetailMode = normalizeRetailMode(rule.UsdRetailMode)
rule.EurRetailMode = normalizeRetailMode(rule.EurRetailMode)
rule.PricingParameterID = item.PricingParameterID
rule.AskiliYan = pricingParameterScopeValue(item.AskiliYan)
rule.Kategori = pricingParameterScopeValue(item.Kategori)
@@ -809,9 +844,7 @@ ORDER BY
rule.BrandCode = pricingParameterScopeValue(item.BrandCode)
rule.BrandGroupSec = pricingParameterScopeValue(item.BrandGroupSec)
item.HasRule = strings.TrimSpace(rule.ID) != ""
if item.HasRule {
item.Rule = &rule
}
item.Rule = &rule
out = append(out, item)
}
return out, rows.Err()

View File

@@ -3,6 +3,7 @@ package queries
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -15,6 +16,22 @@ import (
// - mk_pricex: per-currency multipliers (base + 1..6).
// - mk_priceroll: per-currency rounding steps for wholesale (1-5) and retail (6+).
func normalizeRetailMode(v string) string {
v = strings.ToUpper(strings.TrimSpace(v))
switch v {
case "", "STEP":
return "STEP"
case "END_99", "END_49", "BAND_99", "BAND_49":
return v
default:
return "STEP"
}
}
func NormalizeRetailModeForRoute(v string) string {
return normalizeRetailMode(v)
}
func EnsurePricingRuleTables(pg *sql.DB) error {
stmts := []string{
`
@@ -32,10 +49,26 @@ CREATE TABLE IF NOT EXISTS mk_pricing_rule (
brand_code TEXT[] NOT NULL DEFAULT '{}'::text[],
brand_group TEXT[] NOT NULL DEFAULT '{}'::text[],
strategy_code TEXT NOT NULL DEFAULT 'CORE',
anchor_mode TEXT NOT NULL DEFAULT 'USD',
calc_enabled BOOLEAN NOT NULL DEFAULT TRUE,
publish_postgres BOOLEAN NOT NULL DEFAULT TRUE,
publish_nebim BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS strategy_code TEXT NOT NULL DEFAULT 'CORE'`,
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS anchor_mode TEXT NOT NULL DEFAULT 'USD'`,
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS calc_enabled BOOLEAN NOT NULL DEFAULT TRUE`,
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS publish_postgres BOOLEAN NOT NULL DEFAULT TRUE`,
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS publish_nebim BOOLEAN NOT NULL DEFAULT TRUE`,
`UPDATE mk_pricing_rule SET strategy_code='CORE' WHERE COALESCE(NULLIF(BTRIM(strategy_code), ''), '') = ''`,
`UPDATE mk_pricing_rule SET anchor_mode='USD' WHERE COALESCE(NULLIF(BTRIM(anchor_mode), ''), '') = ''`,
`ALTER TABLE mk_pricing_rule DROP CONSTRAINT IF EXISTS ck_mk_pricing_rule_strategy_code`,
`ALTER TABLE mk_pricing_rule ADD CONSTRAINT ck_mk_pricing_rule_strategy_code CHECK (strategy_code IN ('CORE','PREMIUM','SARTORIAL'))`,
`ALTER TABLE mk_pricing_rule DROP CONSTRAINT IF EXISTS ck_mk_pricing_rule_anchor_mode`,
`ALTER TABLE mk_pricing_rule ADD CONSTRAINT ck_mk_pricing_rule_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))`,
`CREATE INDEX IF NOT EXISTS ix_mk_pricing_rule_active ON mk_pricing_rule (is_active)`,
`
CREATE TABLE IF NOT EXISTS mk_pricex (
@@ -60,13 +93,16 @@ CREATE TABLE IF NOT EXISTS mk_priceroll (
step NUMERIC(18,6) NOT NULL DEFAULT 0,
wholesale_step NUMERIC(18,6) NOT NULL DEFAULT 0,
retail_step NUMERIC(18,6) NOT NULL DEFAULT 0,
retail_mode TEXT NOT NULL DEFAULT 'STEP',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (rule_id, currency)
)`,
`ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS wholesale_step NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS retail_step NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS retail_mode TEXT NOT NULL DEFAULT 'STEP'`,
`UPDATE mk_priceroll SET wholesale_step = step, retail_step = step WHERE step <> 0 AND wholesale_step = 0 AND retail_step = 0`,
`UPDATE mk_priceroll SET retail_mode='STEP' WHERE COALESCE(NULLIF(BTRIM(retail_mode), ''), '') = ''`,
`CREATE INDEX IF NOT EXISTS ix_mk_priceroll_currency ON mk_priceroll (currency)`,
}
for _, s := range stmts {
@@ -92,7 +128,12 @@ type PricingRuleRow struct {
BrandCode []string `json:"brand_code"`
BrandGroupSec []string `json:"brand_group"`
IsActive bool `json:"is_active"`
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"`
// multipliers/rolls are per currency
TryBase float64 `json:"try_base"`
@@ -104,6 +145,7 @@ type PricingRuleRow 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"`
@@ -114,6 +156,7 @@ type PricingRuleRow 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"`
@@ -124,6 +167,7 @@ type PricingRuleRow struct {
Eur6 float64 `json:"eur6"`
EurWholesaleStep float64 `json:"eur_wholesale_step"`
EurRetailStep float64 `json:"eur_retail_step"`
EurRetailMode string `json:"eur_retail_mode"`
}
type PricingRuleSaveItem struct {
@@ -141,7 +185,12 @@ type PricingRuleSaveItem struct {
BrandCode []string `json:"brand_code"`
BrandGroupSec []string `json:"brand_group"`
IsActive bool `json:"is_active"`
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"`
@@ -152,6 +201,7 @@ type PricingRuleSaveItem 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"`
@@ -162,6 +212,7 @@ type PricingRuleSaveItem 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"`
@@ -172,6 +223,174 @@ type PricingRuleSaveItem struct {
Eur6 float64 `json:"eur6"`
EurWholesaleStep float64 `json:"eur_wholesale_step"`
EurRetailStep float64 `json:"eur_retail_step"`
EurRetailMode string `json:"eur_retail_mode"`
}
// BulkSavePricingRulesFast persists multipliers + rounding steps in a set-based way.
// This is intentionally "dumb": it updates/creates a mk_pricing_rule row (latest by pricing_parameter_id)
// and upserts mk_pricex/mk_priceroll for TRY/USD/EUR.
func BulkSavePricingRulesFast(ctx context.Context, tx *sql.Tx, items []PricingRuleSaveItem) (int, error) {
if len(items) == 0 {
return 0, nil
}
raw, err := json.Marshal(items)
if err != nil {
return 0, err
}
// Notes:
// - rule_id resolution:
// 1) explicit id (if provided)
// 2) latest rule for pricing_parameter_id (if provided)
// 3) otherwise new UUID
// - mk_pricing_rule has no unique constraint on pricing_parameter_id by design, so we target "latest" row.
// - created_at uses default; updated_at is bumped on every save.
q := `
WITH input AS (
SELECT *
FROM jsonb_to_recordset($1::jsonb) AS x(
id text,
pricing_parameter_id bigint,
calc_enabled boolean,
publish_postgres boolean,
publish_nebim boolean,
is_active boolean,
try_retail_mode text,
usd_retail_mode text,
eur_retail_mode text,
try_base float8, try1 float8, try2 float8, try3 float8, try4 float8, try5 float8, try6 float8,
try_wholesale_step float8, try_retail_step float8,
usd_base float8, usd1 float8, usd2 float8, usd3 float8, usd4 float8, usd5 float8, usd6 float8,
usd_wholesale_step float8, usd_retail_step float8,
eur_base float8, eur1 float8, eur2 float8, eur3 float8, eur4 float8, eur5 float8, eur6 float8,
eur_wholesale_step float8, eur_retail_step float8
)
),
norm AS (
SELECT
NULLIF(BTRIM(id), '') AS id_txt,
COALESCE(pricing_parameter_id, 0) AS pricing_parameter_id,
COALESCE(calc_enabled, TRUE) AS calc_enabled,
COALESCE(publish_postgres, TRUE) AS publish_postgres,
COALESCE(publish_nebim, TRUE) AS publish_nebim,
COALESCE(is_active, TRUE) AS is_active,
COALESCE(NULLIF(UPPER(BTRIM(try_retail_mode)), ''), 'STEP') AS try_retail_mode,
COALESCE(NULLIF(UPPER(BTRIM(usd_retail_mode)), ''), 'STEP') AS usd_retail_mode,
COALESCE(NULLIF(UPPER(BTRIM(eur_retail_mode)), ''), 'STEP') AS eur_retail_mode,
COALESCE(try_base, 0) AS try_base, COALESCE(try1, 0) AS try1, COALESCE(try2, 0) AS try2, COALESCE(try3, 0) AS try3, COALESCE(try4, 0) AS try4, COALESCE(try5, 0) AS try5, COALESCE(try6, 0) AS try6,
COALESCE(try_wholesale_step, 0) AS try_wholesale_step, COALESCE(try_retail_step, 0) AS try_retail_step,
COALESCE(usd_base, 0) AS usd_base, COALESCE(usd1, 0) AS usd1, COALESCE(usd2, 0) AS usd2, COALESCE(usd3, 0) AS usd3, COALESCE(usd4, 0) AS usd4, COALESCE(usd5, 0) AS usd5, COALESCE(usd6, 0) AS usd6,
COALESCE(usd_wholesale_step, 0) AS usd_wholesale_step, COALESCE(usd_retail_step, 0) AS usd_retail_step,
COALESCE(eur_base, 0) AS eur_base, COALESCE(eur1, 0) AS eur1, COALESCE(eur2, 0) AS eur2, COALESCE(eur3, 0) AS eur3, COALESCE(eur4, 0) AS eur4, COALESCE(eur5, 0) AS eur5, COALESCE(eur6, 0) AS eur6,
COALESCE(eur_wholesale_step, 0) AS eur_wholesale_step, COALESCE(eur_retail_step, 0) AS eur_retail_step
FROM input
),
resolved AS (
SELECT
COALESCE(
NULLIF(id_txt, '')::uuid,
latest.id,
gen_random_uuid()
) AS rule_id,
pricing_parameter_id,
calc_enabled,
publish_postgres,
publish_nebim,
is_active,
try_retail_mode,
usd_retail_mode,
eur_retail_mode,
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
FROM norm n
LEFT JOIN LATERAL (
SELECT r.id
FROM mk_pricing_rule r
WHERE r.pricing_parameter_id = n.pricing_parameter_id
ORDER BY r.created_at DESC, r.updated_at DESC, r.id DESC
LIMIT 1
) latest ON (n.id_txt IS NULL AND n.pricing_parameter_id > 0)
),
upsert_rule AS (
INSERT INTO mk_pricing_rule (
id,
pricing_parameter_id,
calc_enabled,
publish_postgres,
publish_nebim,
is_active,
updated_at
)
SELECT
rule_id,
NULLIF(pricing_parameter_id, 0),
calc_enabled,
publish_postgres,
publish_nebim,
is_active,
now()
FROM resolved
ON CONFLICT (id) DO UPDATE SET
pricing_parameter_id = EXCLUDED.pricing_parameter_id,
calc_enabled = EXCLUDED.calc_enabled,
publish_postgres = EXCLUDED.publish_postgres,
publish_nebim = EXCLUDED.publish_nebim,
is_active = EXCLUDED.is_active,
updated_at = now()
RETURNING id
),
upsert_pricex AS (
INSERT INTO mk_pricex (rule_id, currency, base_mult, m1, m2, m3, m4, m5, m6, updated_at)
SELECT rule_id, 'TRY', try_base, try1, try2, try3, try4, try5, try6, now() FROM resolved
UNION ALL
SELECT rule_id, 'USD', usd_base, usd1, usd2, usd3, usd4, usd5, usd6, now() FROM resolved
UNION ALL
SELECT rule_id, 'EUR', eur_base, eur1, eur2, eur3, eur4, eur5, eur6, now() FROM resolved
ON CONFLICT (rule_id, currency) DO UPDATE SET
base_mult = EXCLUDED.base_mult,
m1 = EXCLUDED.m1,
m2 = EXCLUDED.m2,
m3 = EXCLUDED.m3,
m4 = EXCLUDED.m4,
m5 = EXCLUDED.m5,
m6 = EXCLUDED.m6,
updated_at = now()
RETURNING 1
),
upsert_priceroll AS (
INSERT INTO mk_priceroll (rule_id, currency, wholesale_step, retail_step, retail_mode, updated_at)
SELECT rule_id, 'TRY', try_wholesale_step, try_retail_step, try_retail_mode, now() FROM resolved
UNION ALL
SELECT rule_id, 'USD', usd_wholesale_step, usd_retail_step, usd_retail_mode, now() FROM resolved
UNION ALL
SELECT rule_id, 'EUR', eur_wholesale_step, eur_retail_step, eur_retail_mode, now() FROM resolved
ON CONFLICT (rule_id, currency) DO UPDATE SET
wholesale_step = EXCLUDED.wholesale_step,
retail_step = EXCLUDED.retail_step,
retail_mode = EXCLUDED.retail_mode,
updated_at = now()
RETURNING 1
)
SELECT COUNT(*)::int FROM resolved;
`
var updated int
if err := tx.QueryRowContext(ctx, q, raw).Scan(&updated); err != nil {
return 0, err
}
return updated, nil
}
func ListPricingRules(ctx context.Context, pg *sql.DB) ([]PricingRuleRow, error) {
@@ -190,6 +409,11 @@ SELECT
r.marka,
r.brand_code,
r.brand_group,
r.strategy_code,
r.anchor_mode,
r.calc_enabled,
r.publish_postgres,
r.publish_nebim,
r.is_active,
COALESCE(tx.base_mult, 0)::float8 AS try_base,
@@ -201,6 +425,7 @@ SELECT
COALESCE(tx.m6, 0)::float8 AS try6,
COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8 AS try_wholesale_step,
COALESCE(NULLIF(tr.retail_step, 0), tr.step, 0)::float8 AS try_retail_step,
COALESCE(NULLIF(BTRIM(tr.retail_mode), ''), 'STEP') AS try_retail_mode,
COALESCE(ux.base_mult, 0)::float8 AS usd_base,
COALESCE(ux.m1, 0)::float8 AS usd1,
@@ -211,6 +436,7 @@ SELECT
COALESCE(ux.m6, 0)::float8 AS usd6,
COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8 AS usd_wholesale_step,
COALESCE(NULLIF(ur.retail_step, 0), ur.step, 0)::float8 AS usd_retail_step,
COALESCE(NULLIF(BTRIM(ur.retail_mode), ''), 'STEP') AS usd_retail_mode,
COALESCE(ex.base_mult, 0)::float8 AS eur_base,
COALESCE(ex.m1, 0)::float8 AS eur1,
@@ -220,7 +446,8 @@ SELECT
COALESCE(ex.m5, 0)::float8 AS eur5,
COALESCE(ex.m6, 0)::float8 AS eur6,
COALESCE(NULLIF(er.wholesale_step, 0), er.step, 0)::float8 AS eur_wholesale_step,
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8 AS eur_retail_step
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8 AS eur_retail_step,
COALESCE(NULLIF(BTRIM(er.retail_mode), ''), 'STEP') AS eur_retail_mode
FROM mk_pricing_rule r
LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY'
LEFT JOIN mk_pricex ux ON ux.rule_id = r.id AND ux.currency='USD'
@@ -252,14 +479,22 @@ ORDER BY r.created_at DESC;
pq.Array(&r.Marka),
pq.Array(&r.BrandCode),
pq.Array(&r.BrandGroupSec),
&r.StrategyCode,
&r.AnchorMode,
&r.CalcEnabled,
&r.PublishPostgres,
&r.PublishNebim,
&r.IsActive,
&r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryWholesaleStep, &r.TryRetailStep,
&r.UsdBase, &r.Usd1, &r.Usd2, &r.Usd3, &r.Usd4, &r.Usd5, &r.Usd6, &r.UsdWholesaleStep, &r.UsdRetailStep,
&r.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurWholesaleStep, &r.EurRetailStep,
&r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryWholesaleStep, &r.TryRetailStep, &r.TryRetailMode,
&r.UsdBase, &r.Usd1, &r.Usd2, &r.Usd3, &r.Usd4, &r.Usd5, &r.Usd6, &r.UsdWholesaleStep, &r.UsdRetailStep, &r.UsdRetailMode,
&r.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurWholesaleStep, &r.EurRetailStep, &r.EurRetailMode,
); err != nil {
return nil, err
}
r.TryRetailMode = normalizeRetailMode(r.TryRetailMode)
r.UsdRetailMode = normalizeRetailMode(r.UsdRetailMode)
r.EurRetailMode = normalizeRetailMode(r.EurRetailMode)
out = append(out, r)
}
return out, rows.Err()
@@ -282,6 +517,42 @@ func normalizeTextList(in []string) []string {
return out
}
func deriveStrategyCodeFromBrandGroup(values []string) string {
for _, value := range values {
normalized := strings.ToUpper(strings.TrimSpace(value))
switch normalized {
case "CORE", "PREMIUM", "SARTORIAL":
return normalized
}
}
return "CORE"
}
func deriveAnchorModeFromBrandGroup(ctx context.Context, tx *sql.Tx, values []string) string {
for _, value := range values {
normalized := strings.TrimSpace(value)
if normalized == "" {
continue
}
var mode string
err := tx.QueryRowContext(ctx, `
SELECT anchor_mode
FROM mk_brandgrp
WHERE UPPER(BTRIM(code)) = UPPER(BTRIM($1))
OR UPPER(BTRIM(title)) = UPPER(BTRIM($1))
ORDER BY id
LIMIT 1
`, normalized).Scan(&mode)
if err == nil {
mode = strings.ToUpper(strings.TrimSpace(mode))
if mode == "TRY" || mode == "USD" {
return mode
}
}
}
return "USD"
}
// UpsertPricingRule persists rule scope + per-currency multipliers/roundings.
// Parameter-backed worksheet saves append a new rule version so older prices
// remain queryable. Legacy rules without a parameter id keep update behavior.
@@ -306,6 +577,11 @@ func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem
item.Marka = normalizeTextList(item.Marka)
item.BrandCode = normalizeTextList(item.BrandCode)
item.BrandGroupSec = normalizeTextList(item.BrandGroupSec)
item.StrategyCode = deriveStrategyCodeFromBrandGroup(item.BrandGroupSec)
item.AnchorMode = deriveAnchorModeFromBrandGroup(ctx, tx, item.BrandGroupSec)
item.TryRetailMode = normalizeRetailMode(item.TryRetailMode)
item.UsdRetailMode = normalizeRetailMode(item.UsdRetailMode)
item.EurRetailMode = normalizeRetailMode(item.EurRetailMode)
id := strings.TrimSpace(item.ID)
if item.PricingParameterID > 0 {
@@ -317,12 +593,15 @@ func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem
INSERT INTO mk_pricing_rule (
pricing_parameter_id,
askili_yan,kategori,urun_ilk_grubu,urun_ana_grubu,urun_alt_grubu,
icerik,karisim,marka,brand_code,brand_group,is_active,created_at,updated_at
icerik,karisim,marka,brand_code,brand_group,
strategy_code,anchor_mode,calc_enabled,publish_postgres,publish_nebim,
is_active,created_at,updated_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now())
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,now(),now())
RETURNING id
`, nullablePricingParameterID(item.PricingParameterID), pq.Array(item.AskiliYan), pq.Array(item.Kategori), pq.Array(item.UrunIlkGrubu), pq.Array(item.UrunAnaGrubu), pq.Array(item.UrunAltGrubu),
pq.Array(item.Icerik), pq.Array(item.Karisim), pq.Array(item.Marka), pq.Array(item.BrandCode), pq.Array(item.BrandGroupSec),
item.StrategyCode, item.AnchorMode, item.CalcEnabled, item.PublishPostgres, item.PublishNebim,
item.IsActive,
).Scan(&id); err != nil {
return "", err
@@ -341,13 +620,19 @@ UPDATE mk_pricing_rule SET
marka=$10,
brand_code=$11,
brand_group=$12,
is_active=$13,
strategy_code=$13,
anchor_mode=$14,
calc_enabled=$15,
publish_postgres=$16,
publish_nebim=$17,
is_active=$18,
updated_at=now()
WHERE id=$1
`, id,
nullablePricingParameterID(item.PricingParameterID),
pq.Array(item.AskiliYan), pq.Array(item.Kategori), pq.Array(item.UrunIlkGrubu), pq.Array(item.UrunAnaGrubu), pq.Array(item.UrunAltGrubu),
pq.Array(item.Icerik), pq.Array(item.Karisim), pq.Array(item.Marka), pq.Array(item.BrandCode), pq.Array(item.BrandGroupSec),
item.StrategyCode, item.AnchorMode, item.CalcEnabled, item.PublishPostgres, item.PublishNebim,
item.IsActive,
); err != nil {
return "", err
@@ -371,41 +656,176 @@ ON CONFLICT (rule_id, currency) DO UPDATE SET
`, id, cur, base, m1, m2, m3, m4, m5, m6)
return err
}
upsertRoll := func(cur string, wholesaleStep, retailStep float64) error {
upsertRoll := func(cur string, wholesaleStep, retailStep float64, retailMode string) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,now(),now())
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, retail_mode, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,now(),now())
ON CONFLICT (rule_id, currency) DO UPDATE SET
step=EXCLUDED.step,
wholesale_step=EXCLUDED.wholesale_step,
retail_step=EXCLUDED.retail_step,
retail_mode=EXCLUDED.retail_mode,
updated_at=now()
`, id, cur, wholesaleStep, wholesaleStep, retailStep)
`, id, cur, wholesaleStep, wholesaleStep, retailStep, retailMode)
return err
}
if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil {
return "", err
}
if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep); err != nil {
if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep, item.TryRetailMode); err != nil {
return "", err
}
if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil {
return "", err
}
if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep); err != nil {
if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep, item.UsdRetailMode); err != nil {
return "", err
}
if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil {
return "", err
}
if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep); err != nil {
if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep, item.EurRetailMode); err != nil {
return "", err
}
return id, nil
}
// UpdatePricingRuleByIDFast updates an existing rule without parameter versioning/scope fill.
// This is the fast path for worksheet saves where rule_id is already known.
func UpdatePricingRuleByIDFast(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem) error {
if tx == nil {
return fmt.Errorf("nil tx")
}
ruleID := strings.TrimSpace(item.ID)
if ruleID == "" {
return fmt.Errorf("missing rule id")
}
item.TryRetailMode = normalizeRetailMode(item.TryRetailMode)
item.UsdRetailMode = normalizeRetailMode(item.UsdRetailMode)
item.EurRetailMode = normalizeRetailMode(item.EurRetailMode)
if _, err := tx.ExecContext(ctx, `
UPDATE mk_pricing_rule SET
calc_enabled=$2,
publish_postgres=$3,
publish_nebim=$4,
is_active=$5,
updated_at=now()
WHERE id=$1
`, ruleID, item.CalcEnabled, item.PublishPostgres, item.PublishNebim, item.IsActive); err != nil {
return err
}
upsertX := func(cur string, base, m1, m2, m3, m4, m5, m6 float64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO mk_pricex (rule_id, currency, base_mult, m1, m2, m3, m4, m5, m6, created_at, updated_at)
VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,$7,$8,$9,now(),now())
ON CONFLICT (rule_id, currency) DO UPDATE SET
base_mult=EXCLUDED.base_mult,
m1=EXCLUDED.m1,
m2=EXCLUDED.m2,
m3=EXCLUDED.m3,
m4=EXCLUDED.m4,
m5=EXCLUDED.m5,
m6=EXCLUDED.m6,
updated_at=now()
`, ruleID, cur, base, m1, m2, m3, m4, m5, m6)
return err
}
upsertRoll := func(cur string, wholesaleStep, retailStep float64, retailMode string) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, retail_mode, created_at, updated_at)
VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,now(),now())
ON CONFLICT (rule_id, currency) DO UPDATE SET
step=EXCLUDED.step,
wholesale_step=EXCLUDED.wholesale_step,
retail_step=EXCLUDED.retail_step,
retail_mode=EXCLUDED.retail_mode,
updated_at=now()
`, ruleID, cur, wholesaleStep, wholesaleStep, retailStep, retailMode)
return err
}
if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil {
return err
}
if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep, item.TryRetailMode); err != nil {
return err
}
if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil {
return err
}
if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep, item.UsdRetailMode); err != nil {
return err
}
if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil {
return err
}
if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep, item.EurRetailMode); err != nil {
return err
}
return nil
}
// UpsertPricingRuleByParameterIDFast ensures there is a rule row for a pricing_parameter_id and
// updates its multipliers/roundings in place. This avoids expensive parameter versioning and
// scope fill during worksheet-style bulk saves.
func UpsertPricingRuleByParameterIDFast(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem) (string, error) {
if tx == nil {
return "", fmt.Errorf("nil tx")
}
if item.PricingParameterID <= 0 {
return "", fmt.Errorf("missing pricing_parameter_id")
}
// Find latest rule for this parameter id (if any).
var ruleID string
_ = tx.QueryRowContext(ctx, `
SELECT id::text
FROM mk_pricing_rule
WHERE pricing_parameter_id = $1
ORDER BY created_at DESC, updated_at DESC, id DESC
LIMIT 1
FOR UPDATE
`, item.PricingParameterID).Scan(&ruleID)
ruleID = strings.TrimSpace(ruleID)
if ruleID == "" {
// Create minimal rule row; other fields have defaults and parameter scope is read from mk_urunpricingprmtr.
if err := tx.QueryRowContext(ctx, `
INSERT INTO mk_pricing_rule (
pricing_parameter_id,
calc_enabled,
publish_postgres,
publish_nebim,
is_active,
created_at,
updated_at
)
VALUES ($1,$2,$3,$4,$5,now(),now())
RETURNING id::text
`, item.PricingParameterID, item.CalcEnabled, item.PublishPostgres, item.PublishNebim, item.IsActive).Scan(&ruleID); err != nil {
return "", err
}
ruleID = strings.TrimSpace(ruleID)
}
if ruleID == "" {
return "", fmt.Errorf("failed to resolve rule id")
}
// Reuse the ID-fast updater now that we have an id.
item.ID = ruleID
if err := UpdatePricingRuleByIDFast(ctx, tx, item); err != nil {
return "", err
}
return ruleID, nil
}
func nullablePricingParameterID(id int64) any {
if id <= 0 {
return nil

View File

@@ -115,6 +115,11 @@ func GetAllProductPricingRows(ctx context.Context, chunkSize int, filters Produc
orderExpr = "rc.ProductCode"
orderDir = "ASC"
}
orderBySQL := orderExpr + ` ` + orderDir
if !strings.EqualFold(strings.TrimSpace(orderExpr), "rc.ProductCode") {
orderBySQL += `,
rc.ProductCode ASC`
}
baseQuery := `
IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes;
@@ -230,8 +235,7 @@ func GetAllProductPricingRows(ctx context.Context, chunkSize int, filters Produc
LEFT JOIN #disp_base db
ON db.ItemCode = rc.ProductCode
ORDER BY
` + orderExpr + ` ` + orderDir + `,
rc.ProductCode ASC;
` + orderBySQL + `;
`
rows, err := db.MssqlDB.QueryContext(ctx, baseQuery, args...)
@@ -740,6 +744,11 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
orderExpr = "rc.ProductCode"
orderDir = "ASC"
}
orderBySQL := orderExpr + ` ` + orderDir
if !strings.EqualFold(strings.TrimSpace(orderExpr), "rc.ProductCode") {
orderBySQL += `,
rc.ProductCode ASC`
}
productQuery := `
IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes;
IF OBJECT_ID('tempdb..#stock_base') IS NOT NULL DROP TABLE #stock_base;
@@ -806,8 +815,7 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
LEFT JOIN #stock_base sb
ON sb.ItemCode = rc.ProductCode
ORDER BY
` + orderExpr + ` ` + orderDir + `,
rc.ProductCode ASC
` + orderBySQL + `
OFFSET ` + strconv.Itoa(offset) + ` ROWS
FETCH NEXT ` + strconv.Itoa(limit) + ` ROWS ONLY;
`

View File

@@ -0,0 +1,25 @@
package queries
// GetProductVariantDimsForPricing:
// Pull variant dimension combos from Nebim stock tables (same source as product-stock-query UI).
// We intentionally keep it small: only the keys we need to write dim-aware prices into PG sdprc.
//
// Note: Column semantics depend on your Nebim setup. We treat ItemDim1Code/ItemDim3Code as the
// primary variant dimensions used by the e-commerce sdprc dim filters.
const GetProductVariantDimsForPricing = `
DECLARE @ProductCode NVARCHAR(50) = @p1;
SELECT DISTINCT
LTRIM(RTRIM(ISNULL(S.ColorCode,''))) AS ColorCode,
LTRIM(RTRIM(ISNULL(S.ItemDim1Code,''))) AS ItemDim1Code,
LTRIM(RTRIM(ISNULL(S.ItemDim3Code,''))) AS ItemDim3Code
FROM trStock S WITH(NOLOCK)
WHERE S.ItemTypeCode = 1
AND S.ItemCode = @ProductCode
AND LEN(S.ItemCode) = 13
AND LEN(@ProductCode) = 13
ORDER BY
LTRIM(RTRIM(ISNULL(S.ColorCode,''))),
LTRIM(RTRIM(ISNULL(S.ItemDim1Code,''))),
LTRIM(RTRIM(ISNULL(S.ItemDim3Code,'')));
`

View File

@@ -0,0 +1,362 @@
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
),
dims_cache AS (
SELECT
NULLIF(BTRIM(c.product_code), '') AS product_code,
c.dim1,
c.dim3
FROM mk_mmitem_dim_combo c
JOIN norm
ON norm.product_code = c.product_code
WHERE c.dim1 IS NOT NULL
),
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
}

View File

@@ -0,0 +1,174 @@
package queries
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/lib/pq"
)
// EnqueuePriceRecalc enqueues product codes for delta FX publish.
// It is safe to call repeatedly; duplicates in pending/processing are ignored.
func EnqueuePriceRecalc(ctx context.Context, tx *sql.Tx, productCodes []string, reason string) (int, error) {
if len(productCodes) == 0 {
return 0, nil
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "manual"
}
seen := map[string]struct{}{}
inserted := 0
for _, raw := range productCodes {
code := strings.TrimSpace(raw)
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
_, err := tx.ExecContext(ctx, `
INSERT INTO mk_price_recalc_queue (
product_code, pricing_parameter_id, reason, status, attempts,
available_at, queued_at, processed_at, last_error,
created_at, updated_at
)
VALUES ($1, NULL, $2, 'pending', 0, now(), now(), NULL, '', now(), now())
`, code, reason)
if err != nil {
if pe, ok := err.(*pq.Error); ok && pe != nil && string(pe.Code) == "23505" {
// Duplicate in pending/processing (partial unique index).
continue
}
return inserted, err
}
inserted++
}
return inserted, nil
}
type PriceRecalcQueueItem struct {
ID int64
ProductCode string
Attempts int
}
// ClaimPriceRecalcQueue claims up to limit pending items for processing (SKIP LOCKED).
func ClaimPriceRecalcQueue(ctx context.Context, tx *sql.Tx, limit int) ([]PriceRecalcQueueItem, error) {
if limit <= 0 {
limit = 100
}
rows, err := tx.QueryContext(ctx, `
WITH picked AS (
SELECT id
FROM mk_price_recalc_queue
WHERE status = 'pending'
AND available_at <= now()
ORDER BY queued_at
LIMIT $1
FOR UPDATE SKIP LOCKED
)
UPDATE mk_price_recalc_queue q
SET status = 'processing', updated_at = now()
FROM picked
WHERE q.id = picked.id
RETURNING q.id, q.product_code, q.attempts;
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PriceRecalcQueueItem, 0, limit)
for rows.Next() {
var it PriceRecalcQueueItem
if err := rows.Scan(&it.ID, &it.ProductCode, &it.Attempts); err != nil {
return nil, err
}
it.ProductCode = strings.TrimSpace(it.ProductCode)
out = append(out, it)
}
return out, rows.Err()
}
func MarkPriceRecalcQueueDone(ctx context.Context, tx *sql.Tx, id int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE mk_price_recalc_queue
SET status='done',
processed_at = now(),
updated_at = now(),
last_error=''
WHERE id=$1;
`, id)
return err
}
func MarkPriceRecalcQueueFailed(ctx context.Context, tx *sql.Tx, id int64, attempts int, errText string) error {
errText = strings.TrimSpace(errText)
if len(errText) > 900 {
errText = errText[:900]
}
// Exponential-ish backoff: 5m, 15m, 60m.
delay := 5 * time.Minute
if attempts >= 1 {
delay = 15 * time.Minute
}
if attempts >= 2 {
delay = 60 * time.Minute
}
_, err := tx.ExecContext(ctx, `
UPDATE mk_price_recalc_queue
SET status='failed',
attempts = attempts + 1,
processed_at = now(),
updated_at = now(),
last_error=$2,
available_at = now() + $3::interval
WHERE id=$1;
`, id, errText, fmt.Sprintf("%d seconds", int(delay.Seconds())))
return err
}
// MarkPriceRecalcQueueDoneByProductCodes marks pending/processing rows as done for given product codes.
// This is useful when an immediate publish path completes successfully and we want to avoid a second run.
func MarkPriceRecalcQueueDoneByProductCodes(ctx context.Context, tx *sql.Tx, productCodes []string) (int64, error) {
if len(productCodes) == 0 {
return 0, nil
}
clean := make([]string, 0, len(productCodes))
seen := map[string]struct{}{}
for _, raw := range productCodes {
code := strings.TrimSpace(raw)
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
clean = append(clean, code)
}
if len(clean) == 0 {
return 0, nil
}
res, err := tx.ExecContext(ctx, `
UPDATE mk_price_recalc_queue
SET status='done',
processed_at = now(),
updated_at = now(),
last_error=''
WHERE product_code = ANY($1)
AND status IN ('pending','processing');
`, pq.Array(clean))
if err != nil {
return 0, err
}
ra, _ := res.RowsAffected()
return ra, nil
}