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

950 lines
33 KiB
Go

package queries
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/lib/pq"
)
// Rule tables:
// - mk_pricing_rule: the "scope" (filters) to which multipliers/roundings apply.
// - 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{
`
CREATE TABLE IF NOT EXISTS mk_pricing_rule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
askili_yan TEXT[] NOT NULL DEFAULT '{}'::text[],
kategori TEXT[] NOT NULL DEFAULT '{}'::text[],
urun_ilk_grubu TEXT[] NOT NULL DEFAULT '{}'::text[],
urun_ana_grubu TEXT[] NOT NULL DEFAULT '{}'::text[],
urun_alt_grubu TEXT[] NOT NULL DEFAULT '{}'::text[],
icerik TEXT[] NOT NULL DEFAULT '{}'::text[],
karisim TEXT[] NOT NULL DEFAULT '{}'::text[],
marka TEXT[] NOT NULL DEFAULT '{}'::text[],
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 (
rule_id UUID NOT NULL REFERENCES mk_pricing_rule(id) ON DELETE CASCADE,
currency TEXT NOT NULL CHECK (currency IN ('TRY','USD','EUR')),
base_mult NUMERIC(18,6) NOT NULL DEFAULT 0,
m1 NUMERIC(18,6) NOT NULL DEFAULT 0,
m2 NUMERIC(18,6) NOT NULL DEFAULT 0,
m3 NUMERIC(18,6) NOT NULL DEFAULT 0,
m4 NUMERIC(18,6) NOT NULL DEFAULT 0,
m5 NUMERIC(18,6) NOT NULL DEFAULT 0,
m6 NUMERIC(18,6) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (rule_id, currency)
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_pricex_currency ON mk_pricex (currency)`,
`
CREATE TABLE IF NOT EXISTS mk_priceroll (
rule_id UUID NOT NULL REFERENCES mk_pricing_rule(id) ON DELETE CASCADE,
currency TEXT NOT NULL CHECK (currency IN ('TRY','USD','EUR')),
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 {
if _, err := pg.Exec(s); err != nil {
return err
}
}
return nil
}
type PricingRuleRow struct {
ID string `json:"id"`
PricingParameterID int64 `json:"pricing_parameter_id"`
AskiliYan []string `json:"askili_yan"`
Kategori []string `json:"kategori"`
UrunIlkGrubu []string `json:"urun_ilk_grubu"`
UrunAnaGrubu []string `json:"urun_ana_grubu"`
UrunAltGrubu []string `json:"urun_alt_grubu"`
Icerik []string `json:"icerik"`
Karisim []string `json:"karisim"`
Marka []string `json:"marka"`
BrandCode []string `json:"brand_code"`
BrandGroupSec []string `json:"brand_group"`
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"`
Try1 float64 `json:"try1"`
Try2 float64 `json:"try2"`
Try3 float64 `json:"try3"`
Try4 float64 `json:"try4"`
Try5 float64 `json:"try5"`
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"`
Usd2 float64 `json:"usd2"`
Usd3 float64 `json:"usd3"`
Usd4 float64 `json:"usd4"`
Usd5 float64 `json:"usd5"`
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"`
Eur2 float64 `json:"eur2"`
Eur3 float64 `json:"eur3"`
Eur4 float64 `json:"eur4"`
Eur5 float64 `json:"eur5"`
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 {
ID string `json:"id"`
PricingParameterID int64 `json:"pricing_parameter_id"`
AskiliYan []string `json:"askili_yan"`
Kategori []string `json:"kategori"`
UrunIlkGrubu []string `json:"urun_ilk_grubu"`
UrunAnaGrubu []string `json:"urun_ana_grubu"`
UrunAltGrubu []string `json:"urun_alt_grubu"`
Icerik []string `json:"icerik"`
Karisim []string `json:"karisim"`
Marka []string `json:"marka"`
BrandCode []string `json:"brand_code"`
BrandGroupSec []string `json:"brand_group"`
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"`
Try2 float64 `json:"try2"`
Try3 float64 `json:"try3"`
Try4 float64 `json:"try4"`
Try5 float64 `json:"try5"`
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"`
Usd2 float64 `json:"usd2"`
Usd3 float64 `json:"usd3"`
Usd4 float64 `json:"usd4"`
Usd5 float64 `json:"usd5"`
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"`
Eur2 float64 `json:"eur2"`
Eur3 float64 `json:"eur3"`
Eur4 float64 `json:"eur4"`
Eur5 float64 `json:"eur5"`
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) {
// Use LEFT joins so newly inserted rules show defaults.
q := `
SELECT
r.id,
COALESCE(r.pricing_parameter_id, 0),
r.askili_yan,
r.kategori,
r.urun_ilk_grubu,
r.urun_ana_grubu,
r.urun_alt_grubu,
r.icerik,
r.karisim,
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,
COALESCE(tx.m1, 0)::float8 AS try1,
COALESCE(tx.m2, 0)::float8 AS try2,
COALESCE(tx.m3, 0)::float8 AS try3,
COALESCE(tx.m4, 0)::float8 AS try4,
COALESCE(tx.m5, 0)::float8 AS try5,
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,
COALESCE(ux.m2, 0)::float8 AS usd2,
COALESCE(ux.m3, 0)::float8 AS usd3,
COALESCE(ux.m4, 0)::float8 AS usd4,
COALESCE(ux.m5, 0)::float8 AS usd5,
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,
COALESCE(ex.m2, 0)::float8 AS eur2,
COALESCE(ex.m3, 0)::float8 AS eur3,
COALESCE(ex.m4, 0)::float8 AS eur4,
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(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'
LEFT JOIN mk_pricex ex ON ex.rule_id = r.id AND ex.currency='EUR'
LEFT JOIN mk_priceroll tr ON tr.rule_id = r.id AND tr.currency='TRY'
LEFT JOIN mk_priceroll ur ON ur.rule_id = r.id AND ur.currency='USD'
LEFT JOIN mk_priceroll er ON er.rule_id = r.id AND er.currency='EUR'
ORDER BY r.created_at DESC;
`
rows, err := pg.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PricingRuleRow, 0, 256)
for rows.Next() {
var r PricingRuleRow
if err := rows.Scan(
&r.ID,
&r.PricingParameterID,
pq.Array(&r.AskiliYan),
pq.Array(&r.Kategori),
pq.Array(&r.UrunIlkGrubu),
pq.Array(&r.UrunAnaGrubu),
pq.Array(&r.UrunAltGrubu),
pq.Array(&r.Icerik),
pq.Array(&r.Karisim),
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.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()
}
func normalizeTextList(in []string) []string {
out := make([]string, 0, len(in))
seen := map[string]struct{}{}
for _, v := range in {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
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.
func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem) (string, error) {
if item.PricingParameterID > 0 {
versionedParameterID, err := VersionPricingParameterForRule(ctx, tx, item.PricingParameterID)
if err != nil {
return "", err
}
item.PricingParameterID = versionedParameterID
}
if err := FillPricingRuleScopeFromParameter(ctx, tx, &item); err != nil {
return "", err
}
item.AskiliYan = normalizeTextList(item.AskiliYan)
item.Kategori = normalizeTextList(item.Kategori)
item.UrunIlkGrubu = normalizeTextList(item.UrunIlkGrubu)
item.UrunAnaGrubu = normalizeTextList(item.UrunAnaGrubu)
item.UrunAltGrubu = normalizeTextList(item.UrunAltGrubu)
item.Icerik = normalizeTextList(item.Icerik)
item.Karisim = normalizeTextList(item.Karisim)
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 {
id = ""
}
if id == "" {
// create
if err := tx.QueryRowContext(ctx, `
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,
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,$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
}
} else {
if _, err := tx.ExecContext(ctx, `
UPDATE mk_pricing_rule SET
pricing_parameter_id=$2,
askili_yan=$3,
kategori=$4,
urun_ilk_grubu=$5,
urun_ana_grubu=$6,
urun_alt_grubu=$7,
icerik=$8,
karisim=$9,
marka=$10,
brand_code=$11,
brand_group=$12,
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
}
}
// multipliers upsert helper
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 ($1,$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()
`, id, 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 ($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, 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 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
}
return id
}
type PricingRuleOptionFilters struct {
AskiliYan []string
Kategori []string
UrunIlkGrubu []string
UrunAnaGrubu []string
UrunAltGrubu []string
Icerik []string
Marka []string
BrandCode []string
BrandGroupSec []string
}
// ListPricingRuleDistinctOptions returns distinct values for the requested field, applying cascade filters.
// Source is MSSQL ProductFilterWithDescription('TR').
func ListPricingRuleDistinctOptions(ctx context.Context, mssql *sql.DB, field string, f PricingRuleOptionFilters, limit int) ([]string, error) {
field = strings.TrimSpace(field)
if limit <= 0 {
limit = 500
}
// Map API field -> MSSQL expression (TR descriptions + raw codes where needed)
type fieldExpr struct {
Expr string
// For BrandGroupSec we need to compute from Postgres later; for now it comes from ProductPricing list,
// so we only expose BrandCode/Marka cascades from MSSQL.
}
fieldMap := map[string]string{
"askili_yan": "COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '')",
"kategori": "COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '')",
"urun_ilk_grubu": "COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '')",
"urun_ana_grubu": "COALESCE(LTRIM(RTRIM(ProductAtt01Desc)), '')",
"urun_alt_grubu": "COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '')",
"icerik": "COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '')",
"marka": "COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '')",
"brand_code": "COALESCE(LTRIM(RTRIM(ProductAtt10)), '')",
// "brand_group" is not MSSQL-backed (comes from mk_brandgrpmatch); handled later.
}
expr, ok := fieldMap[field]
if !ok {
return nil, fmt.Errorf("invalid field")
}
// Build WHERE with OR lists like other endpoints
paramIndex := 1
args := make([]any, 0, 64)
nextParam := func() string {
p := "@p" + strconv.Itoa(paramIndex)
paramIndex++
return p
}
whereParts := []string{
"ProductAtt42 IN ('SERI', 'AKSESUAR')",
"IsBlocked = 0",
"LEN(LTRIM(RTRIM(ProductCode))) = 13",
}
addIn := func(expr string, values []string) {
clean := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v != "" {
clean = append(clean, v)
}
}
if len(clean) == 0 {
return
}
ors := make([]string, 0, len(clean))
for _, v := range clean {
p := nextParam()
ors = append(ors, expr+" = "+p)
args = append(args, v)
}
whereParts = append(whereParts, "("+strings.Join(ors, " OR ")+")")
}
addIn(fieldMap["askili_yan"], f.AskiliYan)
addIn(fieldMap["kategori"], f.Kategori)
addIn(fieldMap["urun_ilk_grubu"], f.UrunIlkGrubu)
addIn(fieldMap["urun_ana_grubu"], f.UrunAnaGrubu)
addIn(fieldMap["urun_alt_grubu"], f.UrunAltGrubu)
addIn(fieldMap["icerik"], f.Icerik)
addIn(fieldMap["marka"], f.Marka)
addIn(fieldMap["brand_code"], f.BrandCode)
whereSQL := strings.Join(whereParts, " AND ")
q := `
SELECT TOP (` + strconv.Itoa(limit) + `)
v
FROM (
SELECT DISTINCT ` + expr + ` AS v
FROM ProductFilterWithDescription('TR')
WHERE ` + whereSQL + `
) t
WHERE ISNULL(LTRIM(RTRIM(v)), '') <> ''
ORDER BY v;
`
rows, err := mssql.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, limit)
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, err
}
v = strings.TrimSpace(v)
if v != "" {
out = append(out, v)
}
}
return out, rows.Err()
}