Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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
|
||||
|
||||
679
svc/queries/pricing_calc_engine.go
Normal file
679
svc/queries/pricing_calc_engine.go
Normal 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[:])
|
||||
}
|
||||
188
svc/queries/pricing_calc_infra.go
Normal file
188
svc/queries/pricing_calc_infra.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
|
||||
25
svc/queries/product_pricing_dims_mssql.go
Normal file
25
svc/queries/product_pricing_dims_mssql.go
Normal 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,'')));
|
||||
`
|
||||
362
svc/queries/product_pricing_fx_publish.go
Normal file
362
svc/queries/product_pricing_fx_publish.go
Normal 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
|
||||
}
|
||||
174
svc/queries/product_pricing_recalc_queue.go
Normal file
174
svc/queries/product_pricing_recalc_queue.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user