package queries import ( "context" "database/sql" "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 step (ceil to step). 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[], is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() )`, `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, 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_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"` 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"` TryStep float64 `json:"try_step"` 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"` UsdStep float64 `json:"usd_step"` 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"` EurStep float64 `json:"eur_step"` } 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"` 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"` TryStep float64 `json:"try_step"` 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"` UsdStep float64 `json:"usd_step"` 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"` EurStep float64 `json:"eur_step"` } 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.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(tr.step, 0)::float8 AS try_step, 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(ur.step, 0)::float8 AS usd_step, 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(er.step, 0)::float8 AS eur_step 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.IsActive, &r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryStep, &r.UsdBase, &r.Usd1, &r.Usd2, &r.Usd3, &r.Usd4, &r.Usd5, &r.Usd6, &r.UsdStep, &r.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurStep, ); err != nil { return nil, err } 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 } // 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) 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,is_active,created_at,updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,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.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, is_active=$13, 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.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, step float64) error { _, err := tx.ExecContext(ctx, ` INSERT INTO mk_priceroll (rule_id, currency, step, created_at, updated_at) VALUES ($1,$2,$3,now(),now()) ON CONFLICT (rule_id, currency) DO UPDATE SET step=EXCLUDED.step, updated_at=now() `, id, cur, step) 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.TryStep); 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.UsdStep); 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.EurStep); err != nil { return "", err } return id, 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() }