Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-06-02 16:14:54 +03:00
parent 5f3e975b6d
commit b4e87cfd47
25 changed files with 4918 additions and 287 deletions

View File

@@ -0,0 +1,191 @@
package queries
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/lib/pq"
)
type BrandRow struct {
BrandCode string `json:"brand_code"`
BrandName string `json:"brand_name"`
GroupID int `json:"group_id"`
GroupCode string `json:"group_code"`
GroupName string `json:"group_name"`
}
type BrandGroupOption struct {
ID int `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Description string `json:"description"`
}
func EnsureBrandClassificationTables(pg *sql.DB) error {
stmts := []string{
`
CREATE TABLE IF NOT EXISTS mk_brands (
brand_code TEXT PRIMARY KEY,
brand_name 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()
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_brands_name ON mk_brands (brand_name)`,
`
CREATE TABLE IF NOT EXISTS mk_brandgrp (
id SMALLINT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
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 ''`,
`
INSERT INTO mk_brandgrp (id, code, title, description, sort_order)
VALUES
(1, 'SARTORIAL', 'SARTORIAL', 'Klasik / terzilik odakli ana marka grubu', 1),
(2, 'PREMIUM', 'PREMIUM', 'Ust segment / premium koleksiyon marka grubu', 2),
(3, 'CORE', 'CORE', 'Ana koleksiyon / temel marka grubu', 3)
ON CONFLICT (id) DO NOTHING`,
`UPDATE mk_brandgrp SET description='Klasik / terzilik odakli ana marka grubu' WHERE id=1 AND COALESCE(description,'')=''`,
`UPDATE mk_brandgrp SET description='Ust segment / premium koleksiyon marka grubu' WHERE id=2 AND COALESCE(description,'')=''`,
`UPDATE mk_brandgrp SET description='Ana koleksiyon / temel marka grubu' WHERE id=3 AND COALESCE(description,'')=''`,
`
CREATE TABLE IF NOT EXISTS mk_brandgrpmatch (
brand_code TEXT NOT NULL REFERENCES mk_brands(brand_code) ON DELETE CASCADE,
grp_id SMALLINT NOT NULL REFERENCES mk_brandgrp(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (brand_code)
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_brandgrpmatch_grp ON mk_brandgrpmatch (grp_id)`,
}
for _, s := range stmts {
if _, err := pg.Exec(s); err != nil {
return err
}
}
return nil
}
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`)
if err != nil {
return nil, err
}
defer rows.Close()
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 {
return nil, err
}
o.Code = strings.TrimSpace(o.Code)
o.Title = strings.TrimSpace(o.Title)
o.Description = strings.TrimSpace(o.Description)
out = append(out, o)
}
return out, rows.Err()
}
func ListBrandsWithGroups(ctx context.Context, pg *sql.DB, q string, limit int) ([]BrandRow, error) {
if limit <= 0 {
limit = 5000
}
q = strings.TrimSpace(q)
args := []any{}
where := ""
if q != "" {
args = append(args, "%"+q+"%")
where = "WHERE (b.brand_code ILIKE $1 OR b.brand_name ILIKE $1)"
}
args = append(args, limit)
limitParam := fmt.Sprintf("$%d", len(args))
sqlq := `
SELECT
b.brand_code,
b.brand_name,
COALESCE(m.grp_id, 0) AS group_id,
COALESCE(g.code, '') AS group_code,
COALESCE(g.title, '') AS group_name
FROM mk_brands b
LEFT JOIN mk_brandgrpmatch m ON m.brand_code = b.brand_code
LEFT JOIN mk_brandgrp g ON g.id = m.grp_id
` + where + `
ORDER BY b.brand_code
LIMIT ` + limitParam + `
`
rows, err := pg.QueryContext(ctx, sqlq, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]BrandRow, 0, 1024)
for rows.Next() {
var r BrandRow
if err := rows.Scan(&r.BrandCode, &r.BrandName, &r.GroupID, &r.GroupCode, &r.GroupName); err != nil {
return nil, err
}
r.BrandCode = strings.TrimSpace(r.BrandCode)
r.BrandName = strings.TrimSpace(r.BrandName)
r.GroupCode = strings.TrimSpace(r.GroupCode)
r.GroupName = strings.TrimSpace(r.GroupName)
out = append(out, r)
}
return out, rows.Err()
}
func UpsertBrand(ctx context.Context, tx *sql.Tx, code string, name string, active bool) error {
code = strings.TrimSpace(code)
name = strings.TrimSpace(name)
if code == "" {
return nil
}
_, err := tx.ExecContext(ctx, `
INSERT INTO mk_brands (brand_code, brand_name, is_active, created_at, updated_at)
VALUES ($1, $2, $3, now(), now())
ON CONFLICT (brand_code) DO UPDATE SET
brand_name = EXCLUDED.brand_name,
is_active = EXCLUDED.is_active,
updated_at = now()
`, code, name, active)
return err
}
func DeleteBrandsNotIn(ctx context.Context, tx *sql.Tx, keepCodes []string) error {
// If keepCodes is empty, do nothing (avoid wiping table by mistake).
if len(keepCodes) == 0 {
return nil
}
// Use temp table style deletion via UNNEST.
_, err := tx.ExecContext(ctx, `
DELETE FROM mk_brands
WHERE brand_code NOT IN (SELECT UNNEST($1::text[]))
`, pq.Array(keepCodes))
return err
}
func SetBrandGroup(ctx context.Context, tx *sql.Tx, brandCode string, grpID int) error {
brandCode = strings.TrimSpace(brandCode)
if brandCode == "" {
return nil
}
if grpID <= 0 {
_, err := tx.ExecContext(ctx, `DELETE FROM mk_brandgrpmatch WHERE brand_code=$1`, brandCode)
return err
}
_, err := tx.ExecContext(ctx, `
INSERT INTO mk_brandgrpmatch (brand_code, grp_id, created_at, updated_at)
VALUES ($1, $2, now(), now())
ON CONFLICT (brand_code) DO UPDATE SET
grp_id = EXCLUDED.grp_id,
updated_at = now()
`, brandCode, grpID)
return err
}

117
svc/queries/brand_sync.go Normal file
View File

@@ -0,0 +1,117 @@
package queries
import (
"context"
"database/sql"
"sort"
"strings"
"github.com/lib/pq"
)
type BrandSyncResult struct {
Upserted int `json:"upserted"`
Deleted int `json:"deleted"`
Total int `json:"total"`
}
// SyncBrandsFromMSSQL pulls brand attributes from MSSQL BAGGI_V3 and upserts them into Postgres mk_brands.
// Source: dbo.cdItemAttribute WHERE ItemTypeCode=1 AND AttributeTypeCode=10
func SyncBrandsFromMSSQL(ctx context.Context, mssql *sql.DB, pg *sql.DB) (BrandSyncResult, error) {
out := BrandSyncResult{Upserted: 0, Deleted: 0, Total: 0}
if mssql == nil || pg == nil {
return out, sql.ErrConnDone
}
if err := EnsureBrandClassificationTables(pg); err != nil {
return out, err
}
q := `
SELECT DISTINCT
LTRIM(RTRIM(a.AttributeCode)) AS BrandCode,
COALESCE(NULLIF(LTRIM(RTRIM(d.AttributeDescription)), ''), LTRIM(RTRIM(a.AttributeCode))) AS BrandName,
ISNULL(a.IsBlocked, 0) AS IsBlocked
FROM dbo.cdItemAttribute a WITH(NOLOCK)
LEFT JOIN dbo.cdItemAttributeDesc d WITH(NOLOCK)
ON d.ItemTypeCode = a.ItemTypeCode
AND d.AttributeTypeCode = a.AttributeTypeCode
AND d.AttributeCode = a.AttributeCode
AND d.LangCode = 'TR'
WHERE a.ItemTypeCode = 1
AND a.AttributeTypeCode = 10
AND ISNULL(a.IsBlocked, 0) = 0
AND LEN(LTRIM(RTRIM(a.AttributeCode))) > 0;
`
rows, err := mssql.QueryContext(ctx, q)
if err != nil {
return out, err
}
defer rows.Close()
type srcBrand struct {
Code string
Name string
IsBlocked bool
}
src := make([]srcBrand, 0, 1024)
keepCodes := make([]string, 0, 1024)
seen := make(map[string]struct{}, 2048)
for rows.Next() {
var b srcBrand
if err := rows.Scan(&b.Code, &b.Name, &b.IsBlocked); err != nil {
return out, err
}
b.Code = strings.TrimSpace(b.Code)
if b.Code == "" {
continue
}
if _, ok := seen[b.Code]; ok {
continue
}
seen[b.Code] = struct{}{}
b.Name = strings.TrimSpace(b.Name)
src = append(src, b)
keepCodes = append(keepCodes, b.Code)
}
if err := rows.Err(); err != nil {
return out, err
}
sort.Strings(keepCodes)
out.Total = len(keepCodes)
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
return out, err
}
defer tx.Rollback()
for _, b := range src {
active := !b.IsBlocked
if err := UpsertBrand(ctx, tx, b.Code, b.Name, active); err != nil {
return out, err
}
out.Upserted++
}
if len(keepCodes) > 0 {
res, err := tx.ExecContext(ctx, `
DELETE FROM mk_brands
WHERE brand_code NOT IN (SELECT UNNEST($1::text[]))
`, pq.Array(keepCodes))
if err != nil {
return out, err
}
if n, _ := res.RowsAffected(); n > 0 {
out.Deleted = int(n)
}
}
if err := tx.Commit(); err != nil {
return out, err
}
return out, nil
}

View File

@@ -34,6 +34,28 @@ DO UPDATE SET
`
const ListRoleDepartmentMembers = `
SELECT DISTINCT
u.id,
COALESCE(NULLIF(BTRIM(u.full_name), ''), u.username) AS full_name,
u.username
FROM mk_dfusr u
JOIN dfrole_usr ru
ON ru.dfusr_id = u.id
AND ru.dfrole_id = $1
JOIN dfusr_dprt ud
ON ud.dfusr_id = u.id
AND ud.is_active = TRUE
JOIN mk_dprt d
ON d.id = ud.dprt_id
AND d.code = $2
WHERE u.is_active = TRUE
ORDER BY
COALESCE(NULLIF(BTRIM(u.full_name), ''), u.username),
u.username,
u.id
`
// LIST (role+department sets with summary)
const ListRoleDepartmentPermissionSets = `
WITH role_dept AS (
@@ -88,7 +110,37 @@ SELECT
AND pa.department_code = b.department_code
),
'{}'::jsonb
) AS module_flags
) AS module_flags,
COALESCE(
(
SELECT jsonb_agg(
jsonb_build_object(
'id', member.id,
'full_name', member.full_name,
'username', member.username
)
ORDER BY member.full_name, member.username, member.id
)
FROM (
SELECT DISTINCT
u.id,
COALESCE(NULLIF(BTRIM(u.full_name), ''), u.username) AS full_name,
u.username
FROM mk_dfusr u
JOIN dfrole_usr ru
ON ru.dfusr_id = u.id
AND ru.dfrole_id = b.role_id
JOIN dfusr_dprt ud
ON ud.dfusr_id = u.id
AND ud.is_active = TRUE
JOIN mk_dprt member_dept
ON member_dept.id = ud.dprt_id
AND member_dept.code = b.department_code
WHERE u.is_active = TRUE
) member
),
'[]'::jsonb
) AS members
FROM base b
ORDER BY
b.role_title,

View File

@@ -0,0 +1,712 @@
package queries
import (
"context"
"crypto/md5"
"database/sql"
"encoding/hex"
"fmt"
"log"
"strings"
"time"
"github.com/lib/pq"
)
type PricingParameterSyncResult struct {
Total int `json:"total"`
Upserted int `json:"upserted"`
Deactivated int `json:"deactivated"`
}
type pricingParameterRow struct {
AskiliYan string
Kategori string
UrunIlkGrubu string
UrunAnaGrubu string
UrunAltGrubu string
Icerik string
Marka string
BrandCode string
BrandGroupSec string
}
type PricingParameterRuleRow struct {
PricingParameterID int64 `json:"pricing_parameter_id"`
ScopeKey string `json:"scope_key"`
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"`
Marka string `json:"marka"`
BrandCode string `json:"brand_code"`
BrandGroupSec string `json:"brand_group"`
HasRule bool `json:"has_rule"`
Rule *PricingRuleRow `json:"rule"`
}
// EnsurePricingParameterTables keeps the MSSQL-derived cascade cache close to
// the pricing rules. Rows are retained when they disappear from MSSQL and
// marked inactive so historical rule scopes remain understandable.
func EnsurePricingParameterTables(pg *sql.DB) error {
stmts := []string{
`
CREATE TABLE IF NOT EXISTS mk_urunpricingprmtr (
id BIGSERIAL PRIMARY KEY,
askili_yan TEXT NOT NULL DEFAULT '',
kategori TEXT NOT NULL DEFAULT '',
urun_ilk_grubu TEXT NOT NULL DEFAULT '',
urun_ana_grubu TEXT NOT NULL DEFAULT '',
urun_alt_grubu TEXT NOT NULL DEFAULT '',
icerik TEXT NOT NULL DEFAULT '',
marka TEXT NOT NULL DEFAULT '',
brand_code TEXT NOT NULL DEFAULT '',
brand_group_sec TEXT NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scope_key TEXT GENERATED ALWAYS AS (
md5(askili_yan || chr(31) || kategori || chr(31) || urun_ilk_grubu ||
chr(31) || urun_ana_grubu || chr(31) || urun_alt_grubu || chr(31) ||
icerik || chr(31) || marka || chr(31) || brand_code || chr(31) ||
brand_group_sec)
) STORED
)`,
`DROP INDEX IF EXISTS ux_mk_urunpricingprmtr_scope`,
`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema=current_schema()
AND table_name='mk_urunpricingprmtr'
AND column_name='karisim'
) THEN
DROP INDEX IF EXISTS ux_mk_urunpricingprmtr_active_scope;
DROP INDEX IF EXISTS ix_mk_urunpricingprmtr_scope_history;
ALTER TABLE mk_urunpricingprmtr DROP COLUMN IF EXISTS scope_key;
ALTER TABLE mk_urunpricingprmtr DROP COLUMN karisim;
END IF;
END $$`,
`
ALTER TABLE mk_urunpricingprmtr
ADD COLUMN IF NOT EXISTS scope_key TEXT GENERATED ALWAYS AS (
md5(askili_yan || chr(31) || kategori || chr(31) || urun_ilk_grubu ||
chr(31) || urun_ana_grubu || chr(31) || urun_alt_grubu || chr(31) ||
icerik || chr(31) || marka || chr(31) || brand_code || chr(31) ||
brand_group_sec)
) STORED`,
`
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (PARTITION BY scope_key ORDER BY last_seen_at DESC, id DESC) AS rn
FROM mk_urunpricingprmtr
WHERE is_active=TRUE
)
UPDATE mk_urunpricingprmtr p
SET is_active=FALSE
FROM ranked r
WHERE p.id=r.id
AND r.rn > 1`,
`CREATE UNIQUE INDEX IF NOT EXISTS ux_mk_urunpricingprmtr_active_scope ON mk_urunpricingprmtr (scope_key) WHERE is_active = TRUE`,
`CREATE INDEX IF NOT EXISTS ix_mk_urunpricingprmtr_scope_history ON mk_urunpricingprmtr (scope_key, last_seen_at DESC, id DESC)`,
`CREATE INDEX IF NOT EXISTS ix_mk_urunpricingprmtr_active ON mk_urunpricingprmtr (is_active)`,
`CREATE INDEX IF NOT EXISTS ix_mk_urunpricingprmtr_ilk_ana ON mk_urunpricingprmtr (urun_ilk_grubu, urun_ana_grubu) WHERE is_active = TRUE`,
`CREATE INDEX IF NOT EXISTS ix_mk_urunpricingprmtr_brand ON mk_urunpricingprmtr (brand_code, brand_group_sec) WHERE is_active = TRUE`,
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS pricing_parameter_id BIGINT REFERENCES mk_urunpricingprmtr(id) ON DELETE SET NULL`,
`DROP INDEX IF EXISTS ux_mk_pricing_rule_parameter`,
`CREATE INDEX IF NOT EXISTS ix_mk_pricing_rule_parameter_latest ON mk_pricing_rule (pricing_parameter_id, created_at DESC, updated_at DESC) WHERE pricing_parameter_id IS NOT NULL`,
}
for _, stmt := range stmts {
if _, err := pg.Exec(stmt); err != nil {
return err
}
}
return nil
}
func FillPricingRuleScopeFromParameter(ctx context.Context, tx *sql.Tx, item *PricingRuleSaveItem) error {
if item == nil || item.PricingParameterID <= 0 {
return nil
}
var p pricingParameterRow
if err := tx.QueryRowContext(ctx, `
SELECT
askili_yan, kategori, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu,
icerik, marka, brand_code, brand_group_sec
FROM mk_urunpricingprmtr
WHERE id=$1 AND is_active=TRUE
`, item.PricingParameterID).Scan(
&p.AskiliYan,
&p.Kategori,
&p.UrunIlkGrubu,
&p.UrunAnaGrubu,
&p.UrunAltGrubu,
&p.Icerik,
&p.Marka,
&p.BrandCode,
&p.BrandGroupSec,
); err != nil {
return err
}
item.AskiliYan = pricingParameterScopeValue(p.AskiliYan)
item.Kategori = pricingParameterScopeValue(p.Kategori)
item.UrunIlkGrubu = pricingParameterScopeValue(p.UrunIlkGrubu)
item.UrunAnaGrubu = pricingParameterScopeValue(p.UrunAnaGrubu)
item.UrunAltGrubu = pricingParameterScopeValue(p.UrunAltGrubu)
item.Icerik = pricingParameterScopeValue(p.Icerik)
item.Karisim = nil
item.Marka = pricingParameterScopeValue(p.Marka)
item.BrandCode = pricingParameterScopeValue(p.BrandCode)
item.BrandGroupSec = pricingParameterScopeValue(p.BrandGroupSec)
return nil
}
func VersionPricingParameterForRule(ctx context.Context, tx *sql.Tx, pricingParameterID int64) (int64, error) {
if pricingParameterID <= 0 {
return 0, nil
}
var p pricingParameterRow
var scopeKey string
if err := tx.QueryRowContext(ctx, `
SELECT
askili_yan, kategori, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu,
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,
&p.UrunIlkGrubu,
&p.UrunAnaGrubu,
&p.UrunAltGrubu,
&p.Icerik,
&p.Marka,
&p.BrandCode,
&p.BrandGroupSec,
&scopeKey,
); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `
UPDATE mk_urunpricingprmtr
SET is_active=FALSE,
last_seen_at=now()
WHERE scope_key=$1
AND is_active=TRUE
`, scopeKey); err != nil {
return 0, err
}
var newID int64
if err := tx.QueryRowContext(ctx, `
INSERT INTO mk_urunpricingprmtr (
askili_yan, kategori, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu,
icerik, marka, brand_code, brand_group_sec,
is_active, first_seen_at, last_seen_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE,now(),now())
RETURNING id
`,
p.AskiliYan,
p.Kategori,
p.UrunIlkGrubu,
p.UrunAnaGrubu,
p.UrunAltGrubu,
p.Icerik,
p.Marka,
p.BrandCode,
p.BrandGroupSec,
).Scan(&newID); err != nil {
return 0, err
}
return newID, nil
}
func pricingParameterScopeValue(value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return []string{value}
}
func SyncPricingParametersFromMSSQL(ctx context.Context, mssql *sql.DB, pg *sql.DB) (PricingParameterSyncResult, error) {
out := PricingParameterSyncResult{}
startedAt := time.Now()
if mssql == nil || pg == nil {
return out, sql.ErrConnDone
}
if err := EnsurePricingRuleTables(pg); err != nil {
return out, err
}
if err := EnsurePricingParameterTables(pg); err != nil {
return out, err
}
if err := EnsureBrandClassificationTables(pg); err != nil {
return out, err
}
rows, err := mssql.QueryContext(ctx, `
SELECT DISTINCT
COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '') AS AskiliYan,
COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '') AS Kategori,
COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '') AS UrunIlkGrubu,
COALESCE(LTRIM(RTRIM(ProductAtt01Desc)), '') AS UrunAnaGrubu,
COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '') AS UrunAltGrubu,
COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '') AS Icerik,
COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') AS Marka,
COALESCE(LTRIM(RTRIM(ProductAtt10)), '') AS BrandCode
FROM ProductFilterWithDescription('TR')
WHERE ProductAtt42 IN ('SERI', 'AKSESUAR')
AND IsBlocked = 0
AND LEN(LTRIM(RTRIM(ProductCode))) = 13;
`)
if err != nil {
return out, err
}
defer rows.Close()
src := make([]pricingParameterRow, 0, 4096)
for rows.Next() {
var item pricingParameterRow
if err := rows.Scan(
&item.AskiliYan,
&item.Kategori,
&item.UrunIlkGrubu,
&item.UrunAnaGrubu,
&item.UrunAltGrubu,
&item.Icerik,
&item.Marka,
&item.BrandCode,
); err != nil {
return out, err
}
item = trimPricingParameterRow(item)
src = append(src, item)
}
if err := rows.Err(); err != nil {
return out, err
}
out.Total = len(src)
log.Printf("Pricing parameter sync source loaded: rows=%d duration=%s", out.Total, time.Since(startedAt))
groupByBrand, err := pricingParameterBrandGroups(ctx, pg)
if err != nil {
return out, err
}
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
return out, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
CREATE TEMP TABLE tmp_urunpricingprmtr_sync (
askili_yan TEXT NOT NULL,
kategori TEXT NOT NULL,
urun_ilk_grubu TEXT NOT NULL,
urun_ana_grubu TEXT NOT NULL,
urun_alt_grubu TEXT NOT NULL,
icerik TEXT NOT NULL,
marka TEXT NOT NULL,
brand_code TEXT NOT NULL,
brand_group_sec TEXT NOT NULL,
scope_key TEXT NOT NULL PRIMARY KEY
) ON COMMIT DROP
`); err != nil {
return out, err
}
copyStmt, err := tx.PrepareContext(ctx, pq.CopyIn(
"tmp_urunpricingprmtr_sync",
"askili_yan",
"kategori",
"urun_ilk_grubu",
"urun_ana_grubu",
"urun_alt_grubu",
"icerik",
"marka",
"brand_code",
"brand_group_sec",
"scope_key",
))
if err != nil {
return out, err
}
seenScopeKeys := make(map[string]struct{}, len(src))
for _, item := range src {
item.BrandGroupSec = groupByBrand[item.BrandCode]
scopeKey := pricingParameterScopeKey(item)
if _, exists := seenScopeKeys[scopeKey]; exists {
continue
}
seenScopeKeys[scopeKey] = struct{}{}
if _, err := copyStmt.ExecContext(ctx,
item.AskiliYan,
item.Kategori,
item.UrunIlkGrubu,
item.UrunAnaGrubu,
item.UrunAltGrubu,
item.Icerik,
item.Marka,
item.BrandCode,
item.BrandGroupSec,
scopeKey,
); err != nil {
_ = copyStmt.Close()
return out, err
}
}
if _, err := copyStmt.ExecContext(ctx); err != nil {
_ = copyStmt.Close()
return out, err
}
if err := copyStmt.Close(); err != nil {
return out, err
}
out.Upserted = len(seenScopeKeys)
log.Printf("Pricing parameter sync copy loaded: rows=%d duration=%s", out.Upserted, time.Since(startedAt))
res, err := tx.ExecContext(ctx, `
UPDATE mk_urunpricingprmtr p
SET is_active=FALSE
WHERE p.is_active=TRUE
AND NOT EXISTS (
SELECT 1
FROM tmp_urunpricingprmtr_sync t
WHERE t.scope_key=p.scope_key
)
`)
if err != nil {
return out, err
}
if n, err := res.RowsAffected(); err == nil {
out.Deactivated = int(n)
}
if _, err := tx.ExecContext(ctx, `
UPDATE mk_urunpricingprmtr p
SET last_seen_at=now()
FROM tmp_urunpricingprmtr_sync t
WHERE p.scope_key=t.scope_key
AND p.is_active=TRUE
`); err != nil {
return out, err
}
insertResult, err := tx.ExecContext(ctx, `
INSERT INTO mk_urunpricingprmtr (
askili_yan, kategori, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu,
icerik, marka, brand_code, brand_group_sec,
is_active, first_seen_at, last_seen_at
)
SELECT
askili_yan, kategori, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu,
icerik, marka, brand_code, brand_group_sec,
TRUE, now(), now()
FROM tmp_urunpricingprmtr_sync t
WHERE NOT EXISTS (
SELECT 1
FROM mk_urunpricingprmtr p
WHERE p.scope_key=t.scope_key
AND p.is_active=TRUE
)
`)
if err != nil {
return out, err
}
if n, err := insertResult.RowsAffected(); err == nil {
out.Upserted = int(n)
}
if err := tx.Commit(); err != nil {
return out, err
}
log.Printf("Pricing parameter sync committed: rows=%d duration=%s", out.Upserted, time.Since(startedAt))
return out, nil
}
func pricingParameterScopeKey(item pricingParameterRow) string {
parts := []string{
item.AskiliYan,
item.Kategori,
item.UrunIlkGrubu,
item.UrunAnaGrubu,
item.UrunAltGrubu,
item.Icerik,
item.Marka,
item.BrandCode,
item.BrandGroupSec,
}
sum := md5.Sum([]byte(strings.Join(parts, string(rune(31)))))
return hex.EncodeToString(sum[:])
}
func trimPricingParameterRow(item pricingParameterRow) pricingParameterRow {
item.AskiliYan = strings.TrimSpace(item.AskiliYan)
item.Kategori = strings.TrimSpace(item.Kategori)
item.UrunIlkGrubu = strings.TrimSpace(item.UrunIlkGrubu)
item.UrunAnaGrubu = strings.TrimSpace(item.UrunAnaGrubu)
item.UrunAltGrubu = strings.TrimSpace(item.UrunAltGrubu)
item.Icerik = strings.TrimSpace(item.Icerik)
item.Marka = strings.TrimSpace(item.Marka)
item.BrandCode = strings.TrimSpace(item.BrandCode)
item.BrandGroupSec = strings.TrimSpace(item.BrandGroupSec)
return item
}
func pricingParameterBrandGroups(ctx context.Context, pg *sql.DB) (map[string]string, error) {
rows, err := pg.QueryContext(ctx, `
SELECT m.brand_code, g.title
FROM mk_brandgrpmatch m
JOIN mk_brandgrp g ON g.id = m.grp_id
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]string, 1024)
for rows.Next() {
var code, group string
if err := rows.Scan(&code, &group); err != nil {
return nil, err
}
out[strings.TrimSpace(code)] = strings.TrimSpace(group)
}
return out, rows.Err()
}
func ListPricingParameterDistinctOptions(ctx context.Context, pg *sql.DB, field string, f PricingRuleOptionFilters, limit int) ([]string, error) {
field = strings.TrimSpace(field)
if limit <= 0 {
limit = 500
}
fieldMap := map[string]string{
"askili_yan": "askili_yan",
"kategori": "kategori",
"urun_ilk_grubu": "urun_ilk_grubu",
"urun_ana_grubu": "urun_ana_grubu",
"urun_alt_grubu": "urun_alt_grubu",
"icerik": "icerik",
"marka": "marka",
"brand_code": "brand_code",
"brand_group": "brand_group_sec",
}
target, ok := fieldMap[field]
if !ok {
return nil, fmt.Errorf("invalid field")
}
type filter struct {
Field string
Values []string
}
filters := []filter{
{"askili_yan", f.AskiliYan},
{"kategori", f.Kategori},
{"urun_ilk_grubu", f.UrunIlkGrubu},
{"urun_ana_grubu", f.UrunAnaGrubu},
{"urun_alt_grubu", f.UrunAltGrubu},
{"icerik", f.Icerik},
{"marka", f.Marka},
{"brand_code", f.BrandCode},
{"brand_group", f.BrandGroupSec},
}
args := make([]any, 0, len(filters)+1)
where := []string{"is_active=TRUE", target + " <> ''"}
for _, item := range filters {
if item.Field == field {
continue
}
values := normalizeTextList(item.Values)
if len(values) == 0 {
continue
}
args = append(args, pq.Array(values))
where = append(where, fieldMap[item.Field]+fmt.Sprintf(" = ANY($%d::text[])", len(args)))
}
args = append(args, limit)
rows, err := pg.QueryContext(ctx, `
SELECT DISTINCT `+target+`
FROM mk_urunpricingprmtr
WHERE `+strings.Join(where, " AND ")+`
ORDER BY `+target+`
LIMIT $`+fmt.Sprint(len(args))+`
`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, limit)
for rows.Next() {
var value string
if err := rows.Scan(&value); err != nil {
return nil, err
}
value = strings.TrimSpace(value)
if value != "" {
out = append(out, value)
}
}
return out, rows.Err()
}
func ListPricingParameterRules(ctx context.Context, pg *sql.DB, f PricingRuleOptionFilters) ([]PricingParameterRuleRow, error) {
where, args := pricingParameterFilterSQL(f)
rows, err := pg.QueryContext(ctx, `
SELECT
p.id,
p.scope_key,
p.askili_yan,
p.kategori,
p.urun_ilk_grubu,
p.urun_ana_grubu,
p.urun_alt_grubu,
p.icerik,
p.marka,
p.brand_code,
p.brand_group_sec,
COALESCE(r.id::text, ''),
COALESCE(r.is_active, TRUE),
COALESCE(tx.base_mult, 0)::float8,
COALESCE(tx.m1, 0)::float8,
COALESCE(tx.m2, 0)::float8,
COALESCE(tx.m3, 0)::float8,
COALESCE(tx.m4, 0)::float8,
COALESCE(tx.m5, 0)::float8,
COALESCE(tx.m6, 0)::float8,
COALESCE(tr.step, 0)::float8,
COALESCE(ux.base_mult, 0)::float8,
COALESCE(ux.m1, 0)::float8,
COALESCE(ux.m2, 0)::float8,
COALESCE(ux.m3, 0)::float8,
COALESCE(ux.m4, 0)::float8,
COALESCE(ux.m5, 0)::float8,
COALESCE(ux.m6, 0)::float8,
COALESCE(ur.step, 0)::float8,
COALESCE(ex.base_mult, 0)::float8,
COALESCE(ex.m1, 0)::float8,
COALESCE(ex.m2, 0)::float8,
COALESCE(ex.m3, 0)::float8,
COALESCE(ex.m4, 0)::float8,
COALESCE(ex.m5, 0)::float8,
COALESCE(ex.m6, 0)::float8,
COALESCE(er.step, 0)::float8
FROM mk_urunpricingprmtr p
LEFT JOIN LATERAL (
SELECT latest_rule.*
FROM mk_pricing_rule latest_rule
WHERE latest_rule.pricing_parameter_id = p.id
ORDER BY latest_rule.created_at DESC, latest_rule.updated_at DESC, latest_rule.id DESC
LIMIT 1
) r 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'
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'
WHERE `+strings.Join(where, " AND ")+`
ORDER BY
p.urun_ilk_grubu,
p.urun_ana_grubu,
p.urun_alt_grubu,
p.marka,
p.brand_code,
p.id
`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PricingParameterRuleRow, 0, 1024)
for rows.Next() {
var item PricingParameterRuleRow
rule := PricingRuleRow{}
if err := rows.Scan(
&item.PricingParameterID,
&item.ScopeKey,
&item.AskiliYan,
&item.Kategori,
&item.UrunIlkGrubu,
&item.UrunAnaGrubu,
&item.UrunAltGrubu,
&item.Icerik,
&item.Marka,
&item.BrandCode,
&item.BrandGroupSec,
&rule.ID,
&rule.IsActive,
&rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryStep,
&rule.UsdBase, &rule.Usd1, &rule.Usd2, &rule.Usd3, &rule.Usd4, &rule.Usd5, &rule.Usd6, &rule.UsdStep,
&rule.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurStep,
); err != nil {
return nil, err
}
rule.PricingParameterID = item.PricingParameterID
rule.AskiliYan = pricingParameterScopeValue(item.AskiliYan)
rule.Kategori = pricingParameterScopeValue(item.Kategori)
rule.UrunIlkGrubu = pricingParameterScopeValue(item.UrunIlkGrubu)
rule.UrunAnaGrubu = pricingParameterScopeValue(item.UrunAnaGrubu)
rule.UrunAltGrubu = pricingParameterScopeValue(item.UrunAltGrubu)
rule.Icerik = pricingParameterScopeValue(item.Icerik)
rule.Karisim = nil
rule.Marka = pricingParameterScopeValue(item.Marka)
rule.BrandCode = pricingParameterScopeValue(item.BrandCode)
rule.BrandGroupSec = pricingParameterScopeValue(item.BrandGroupSec)
item.HasRule = strings.TrimSpace(rule.ID) != ""
if item.HasRule {
item.Rule = &rule
}
out = append(out, item)
}
return out, rows.Err()
}
func pricingParameterFilterSQL(f PricingRuleOptionFilters) ([]string, []any) {
type filter struct {
Column string
Values []string
}
filters := []filter{
{"p.askili_yan", f.AskiliYan},
{"p.kategori", f.Kategori},
{"p.urun_ilk_grubu", f.UrunIlkGrubu},
{"p.urun_ana_grubu", f.UrunAnaGrubu},
{"p.urun_alt_grubu", f.UrunAltGrubu},
{"p.icerik", f.Icerik},
{"p.marka", f.Marka},
{"p.brand_code", f.BrandCode},
{"p.brand_group_sec", f.BrandGroupSec},
}
where := []string{"p.is_active=TRUE"}
args := make([]any, 0, len(filters))
for _, item := range filters {
values := normalizeTextList(item.Values)
if len(values) == 0 {
continue
}
args = append(args, pq.Array(values))
where = append(where, item.Column+fmt.Sprintf(" = ANY($%d::text[])", len(args)))
}
return where, args
}

View File

@@ -0,0 +1,513 @@
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()
}

View File

@@ -10,6 +10,8 @@ import (
"strconv"
"strings"
"time"
"github.com/lib/pq"
)
type ProductPricingFilters struct {
@@ -34,7 +36,7 @@ type ProductPricingPage struct {
Limit int
}
func GetProductPricingPage(ctx context.Context, page int, limit int, filters ProductPricingFilters) (ProductPricingPage, error) {
func GetProductPricingPage(ctx context.Context, page int, limit int, filters ProductPricingFilters, includeTotal bool, sortBy string, descending bool) (ProductPricingPage, error) {
result := ProductPricingPage{
Rows: []models.ProductPricing{},
TotalCount: 0,
@@ -114,34 +116,53 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
}
whereSQL := strings.Join(whereParts, " AND ")
countQuery := `
SELECT COUNT(DISTINCT LTRIM(RTRIM(ProductCode)))
FROM ProductFilterWithDescription('TR')
WHERE ` + whereSQL + `;
`
var totalCount int
if err := db.MssqlDB.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil {
return result, err
}
result.TotalCount = totalCount
if totalCount == 0 {
if includeTotal {
countQuery := `
SELECT COUNT(DISTINCT LTRIM(RTRIM(ProductCode)))
FROM ProductFilterWithDescription('TR')
WHERE ` + whereSQL + `;
`
var totalCount int
if err := db.MssqlDB.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil {
return result, err
}
result.TotalCount = totalCount
if totalCount == 0 {
result.TotalPages = 0
result.Page = 1
return result, nil
}
totalPages := int(math.Ceil(float64(totalCount) / float64(limit)))
if totalPages <= 0 {
totalPages = 1
}
if page > totalPages {
page = totalPages
offset = (page - 1) * limit
}
result.Page = page
result.Limit = limit
result.TotalPages = totalPages
} else {
// Skip COUNT(*) for performance; client will infer hasMore from page size.
result.TotalCount = 0
result.TotalPages = 0
result.Page = 1
return result, nil
result.Page = page
result.Limit = limit
}
totalPages := int(math.Ceil(float64(totalCount) / float64(limit)))
if totalPages <= 0 {
totalPages = 1
}
if page > totalPages {
page = totalPages
offset = (page - 1) * limit
}
result.Page = page
result.Limit = limit
result.TotalPages = totalPages
// Stage 1: fetch only paged products first (fast path).
sortBy = strings.TrimSpace(sortBy)
orderDir := "DESC"
if !descending {
orderDir = "ASC"
}
// Only allow a small safe list.
orderExpr := "CAST(ROUND(ISNULL(sb.InventoryQty1, 0), 2) AS DECIMAL(18, 2))"
if sortBy == "productCode" {
orderExpr = "rc.ProductCode"
orderDir = "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;
@@ -156,7 +177,8 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
MAX(f.UrunAltGrubu) AS UrunAltGrubu,
MAX(f.Icerik) AS Icerik,
MAX(f.Karisim) AS Karisim,
MAX(f.Marka) AS Marka
MAX(f.Marka) AS Marka,
MAX(f.BrandCode) AS BrandCode
INTO #req_codes
FROM (
SELECT
@@ -169,7 +191,8 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '') AS UrunAltGrubu,
COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '') AS Icerik,
COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '') AS Karisim,
COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') AS Marka
COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') AS Marka,
COALESCE(LTRIM(RTRIM(ProductAtt10)), '') AS BrandCode
FROM ProductFilterWithDescription('TR')
WHERE ` + whereSQL + `
) f
@@ -200,12 +223,13 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
rc.UrunAltGrubu,
rc.Icerik,
rc.Karisim,
rc.Marka
rc.Marka,
rc.BrandCode
FROM #req_codes rc
LEFT JOIN #stock_base sb
ON sb.ItemCode = rc.ProductCode
ORDER BY
CAST(ROUND(ISNULL(sb.InventoryQty1, 0), 2) AS DECIMAL(18, 2)) DESC,
` + orderExpr + ` ` + orderDir + `,
rc.ProductCode ASC
OFFSET ` + strconv.Itoa(offset) + ` ROWS
FETCH NEXT ` + strconv.Itoa(limit) + ` ROWS ONLY;
@@ -252,6 +276,7 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
&item.Icerik,
&item.Karisim,
&item.Marka,
&item.BrandCode,
); err != nil {
return result, err
}
@@ -284,6 +309,39 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
FROM (VALUES ` + strings.Join(valueRows, ",") + `) v(ProductCode)
WHERE LEN(LTRIM(RTRIM(v.ProductCode))) > 0
),
latest_pricelist_line AS (
-- Base prices from Nebim V3 price lists (trPriceListLine).
-- Pick the latest record per (ItemCode, Currency) using ValidDate/ValidTime, then LastUpdatedDate.
SELECT
LTRIM(RTRIM(p.ItemCode)) AS ItemCode,
LTRIM(RTRIM(p.DocCurrencyCode)) AS DocCurrencyCode,
CAST(p.Price AS DECIMAL(18, 2)) AS Price,
ROW_NUMBER() OVER (
PARTITION BY LTRIM(RTRIM(p.ItemCode)), LTRIM(RTRIM(p.DocCurrencyCode))
ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC
) AS rn
FROM dbo.trPriceListLine p WITH(NOLOCK)
INNER JOIN req_codes rc
ON rc.ProductCode = LTRIM(RTRIM(p.ItemCode))
WHERE p.ItemTypeCode = 1
AND ISNULL(p.IsDisabled, 0) = 0
AND LTRIM(RTRIM(p.DocCurrencyCode)) IN ('USD', 'TRY')
AND (
(LTRIM(RTRIM(p.DocCurrencyCode)) = 'USD' AND LTRIM(RTRIM(p.PriceGroupCode)) = 'TM-USD')
OR (LTRIM(RTRIM(p.DocCurrencyCode)) = 'TRY' AND LTRIM(RTRIM(p.PriceGroupCode)) = 'TM-TRY')
)
AND p.Price IS NOT NULL
AND p.Price > 0
),
base_prices AS (
SELECT
ItemCode,
MAX(CASE WHEN DocCurrencyCode = 'USD' THEN Price END) AS BasePriceUsd,
MAX(CASE WHEN DocCurrencyCode = 'TRY' THEN Price END) AS BasePriceTry
FROM latest_pricelist_line
WHERE rn = 1
GROUP BY ItemCode
),
latest_base_price AS (
SELECT
LTRIM(RTRIM(b.ItemCode)) AS ItemCode,
@@ -365,6 +423,8 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
SELECT
rc.ProductCode,
COALESCE(lp.CostPrice, 0) AS CostPrice,
COALESCE(bp.BasePriceUsd, 0) AS BasePriceUsd,
COALESCE(bp.BasePriceTry, 0) AS BasePriceTry,
CAST(ROUND(
ISNULL(sb.InventoryQty1, 0)
- ISNULL(pb.PickingQty1, 0)
@@ -372,11 +432,14 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
- ISNULL(db.DispOrderQty1, 0)
, 2) AS DECIMAL(18, 2)) AS StockQty,
COALESCE(se.StockEntryDate, '') AS StockEntryDate,
'' AS LastCostingDate,
COALESCE(lp.LastPricingDate, '') AS LastPricingDate
FROM req_codes rc
LEFT JOIN latest_base_price lp
ON lp.ItemCode = rc.ProductCode
AND lp.rn = 1
LEFT JOIN base_prices bp
ON bp.ItemCode = rc.ProductCode
LEFT JOIN stock_entry_dates se
ON se.ItemCode = rc.ProductCode
LEFT JOIN stock_base sb
@@ -397,8 +460,11 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
type metrics struct {
CostPrice float64
BasePriceUsd float64
BasePriceTry float64
StockQty float64
StockEntryDate string
LastCostingDate string
LastPricingDate string
}
metricsByCode := make(map[string]metrics, len(out))
@@ -410,8 +476,11 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
if err := metricsRows.Scan(
&code,
&m.CostPrice,
&m.BasePriceUsd,
&m.BasePriceTry,
&m.StockQty,
&m.StockEntryDate,
&m.LastCostingDate,
&m.LastPricingDate,
); err != nil {
return result, err
@@ -425,12 +494,222 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
for i := range out {
if m, ok := metricsByCode[strings.TrimSpace(out[i].ProductCode)]; ok {
out[i].CostPrice = m.CostPrice
out[i].BasePriceUsd = m.BasePriceUsd
out[i].BasePriceTry = m.BasePriceTry
out[i].StockQty = m.StockQty
out[i].StockEntryDate = m.StockEntryDate
out[i].LastCostingDate = m.LastCostingDate
out[i].LastPricingDate = m.LastPricingDate
}
}
// Stage 3: fetch costing date from UretimDB (separate MSSQL catalog).
// Pricing DB may not contain spUrtOnMLMas; do not fail listing on costing query errors.
if uretimDB := db.GetUretimDB(); uretimDB != nil {
costingQuery := `
WITH req_codes AS (
SELECT DISTINCT LTRIM(RTRIM(v.ProductCode)) AS ProductCode
FROM (VALUES ` + strings.Join(valueRows, ",") + `) v(ProductCode)
WHERE LEN(LTRIM(RTRIM(v.ProductCode))) > 0
)
SELECT
LTRIM(RTRIM(m.UrunKodu)) AS UrunKodu,
CONVERT(VARCHAR(10), MAX(m.Tarihi), 23) AS LastCostingDate
FROM dbo.spUrtOnMLMas m WITH(NOLOCK)
INNER JOIN req_codes rc
ON rc.ProductCode = LTRIM(RTRIM(m.UrunKodu))
GROUP BY LTRIM(RTRIM(m.UrunKodu));
`
costRows, err := uretimDB.QueryContext(ctx, costingQuery, metricArgs...)
if err == nil {
costingByCode := make(map[string]string, len(out))
for costRows.Next() {
var code, d string
if err := costRows.Scan(&code, &d); err != nil {
_ = costRows.Close()
costRows = nil
break
}
costingByCode[strings.TrimSpace(code)] = strings.TrimSpace(d)
}
if costRows != nil {
_ = costRows.Close()
for i := range out {
if d, ok := costingByCode[strings.TrimSpace(out[i].ProductCode)]; ok && d != "" {
out[i].LastCostingDate = d
}
}
}
}
}
// Stage 4: fetch latest tier prices (USD1..6, EUR1..6, TRY1..6) from PostgreSQL sdprc/mmitem.
if pg := db.PgDB; pg != nil {
type tierRow struct {
Code string
Grp int
Crn string
Prc float64
}
tierSQL := `
WITH ranked AS (
SELECT
mmitem.code AS code,
sdprc.sdprcgrp_id AS grp,
sdprc.crn AS crn,
COALESCE(sdprc.prc, 0) AS prc,
ROW_NUMBER() OVER (
PARTITION BY mmitem.code, sdprc.crn, sdprc.sdprcgrp_id
ORDER BY sdprc.zlins_dttm DESC
) AS rn
FROM sdprc
JOIN mmitem ON mmitem.id = sdprc.mmitem_id
WHERE mmitem.code = ANY($1)
AND sdprc.sdprcgrp_id BETWEEN 1 AND 6
AND sdprc.crn IN ('USD', 'EUR', 'TRY')
AND sdprc.prc IS NOT NULL
AND sdprc.prc > 0
)
SELECT code, grp, crn, prc
FROM ranked
WHERE rn = 1;
`
rows, err := pg.QueryContext(ctx, tierSQL, pq.Array(codes))
if err == nil {
defer rows.Close()
type key struct {
Code string
}
tiers := make(map[string]map[string]map[int]float64, len(out))
for rows.Next() {
var r tierRow
if err := rows.Scan(&r.Code, &r.Grp, &r.Crn, &r.Prc); err != nil {
break
}
r.Code = strings.TrimSpace(r.Code)
r.Crn = strings.TrimSpace(strings.ToUpper(r.Crn))
if r.Code == "" || r.Grp < 1 || r.Grp > 6 || r.Crn == "" {
continue
}
if _, ok := tiers[r.Code]; !ok {
tiers[r.Code] = map[string]map[int]float64{}
}
if _, ok := tiers[r.Code][r.Crn]; !ok {
tiers[r.Code][r.Crn] = map[int]float64{}
}
tiers[r.Code][r.Crn][r.Grp] = r.Prc
}
for i := range out {
code := strings.TrimSpace(out[i].ProductCode)
m, ok := tiers[code]
if !ok {
continue
}
apply := func(crn string, grp int, v float64) {
switch crn {
case "USD":
switch grp {
case 1:
out[i].USD1 = v
case 2:
out[i].USD2 = v
case 3:
out[i].USD3 = v
case 4:
out[i].USD4 = v
case 5:
out[i].USD5 = v
case 6:
out[i].USD6 = v
}
case "EUR":
switch grp {
case 1:
out[i].EUR1 = v
case 2:
out[i].EUR2 = v
case 3:
out[i].EUR3 = v
case 4:
out[i].EUR4 = v
case 5:
out[i].EUR5 = v
case 6:
out[i].EUR6 = v
}
case "TRY":
switch grp {
case 1:
out[i].TRY1 = v
case 2:
out[i].TRY2 = v
case 3:
out[i].TRY3 = v
case 4:
out[i].TRY4 = v
case 5:
out[i].TRY5 = v
case 6:
out[i].TRY6 = v
}
}
}
for crn, byGrp := range m {
for grp, v := range byGrp {
apply(crn, grp, v)
}
}
}
}
}
// Stage 5: brand group (classification) from Postgres mk_brandgrpmatch.
// Show classification result in BrandGroupSec field (read-only in UI).
if pg := db.PgDB; pg != nil {
brandCodes := make([]string, 0, len(out))
seen := make(map[string]struct{}, len(out))
for _, it := range out {
code := strings.TrimSpace(it.BrandCode)
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
brandCodes = append(brandCodes, code)
}
if len(brandCodes) > 0 {
rows, err := pg.QueryContext(ctx, `
SELECT
m.brand_code,
COALESCE(g.title, '') AS grp_title
FROM mk_brandgrpmatch m
JOIN mk_brandgrp g ON g.id = m.grp_id
WHERE m.brand_code = ANY($1)
`, pq.Array(brandCodes))
if err == nil {
defer rows.Close()
grpByBrand := make(map[string]string, len(brandCodes))
for rows.Next() {
var code, title string
if err := rows.Scan(&code, &title); err != nil {
break
}
grpByBrand[strings.TrimSpace(code)] = strings.TrimSpace(title)
}
for i := range out {
if title, ok := grpByBrand[strings.TrimSpace(out[i].BrandCode)]; ok {
out[i].BrandGroupSec = title
} else {
out[i].BrandGroupSec = ""
}
}
}
}
}
result.Rows = out
return result, nil
}

View File

@@ -0,0 +1,123 @@
package queries
import (
"bssapp-backend/db"
"context"
"database/sql"
"fmt"
"strings"
)
// GetProductPricingFilterOptions returns distinct option values for ProductPricing filters.
// This is used to render filter dropdowns without loading the full dataset.
func GetProductPricingFilterOptions(ctx context.Context, field string, q string, limit int, scopeUrunIlkGrubu []string) ([]string, error) {
mssql := db.MssqlDB
if mssql == nil {
return nil, fmt.Errorf("mssql db is nil")
}
field = strings.TrimSpace(field)
q = strings.TrimSpace(q)
if limit <= 0 || limit > 200 {
limit = 120
}
if len(scopeUrunIlkGrubu) > 3 {
scopeUrunIlkGrubu = scopeUrunIlkGrubu[:3]
}
// Map UI filter fields -> MSSQL expression in ProductFilterWithDescription('TR')
var expr string
switch field {
case "productCode":
expr = "LTRIM(RTRIM(ProductCode))"
case "brandGroupSelection":
expr = `CASE ABS(CHECKSUM(LTRIM(RTRIM(ProductCode)))) % 3
WHEN 0 THEN 'MARKA GRUBU A'
WHEN 1 THEN 'MARKA GRUBU B'
ELSE 'MARKA GRUBU C'
END`
case "marka":
expr = "COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '')"
case "askiliYan":
expr = "COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '')"
case "kategori":
expr = "COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '')"
case "urunIlkGrubu":
expr = "COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '')"
case "urunAnaGrubu":
expr = "COALESCE(LTRIM(RTRIM(ProductAtt01Desc)), '')"
case "urunAltGrubu":
expr = "COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '')"
case "icerik":
expr = "COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '')"
case "karisim":
expr = "COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '')"
default:
return nil, fmt.Errorf("invalid field")
}
// NOTE: We keep the same base constraints as the listing query.
// q: prefix match to keep it sargable-ish.
args := make([]any, 0, 8)
where := []string{
"ProductAtt42 IN ('SERI', 'AKSESUAR')",
"IsBlocked = 0",
"LEN(LTRIM(RTRIM(ProductCode))) = 13",
}
if len(scopeUrunIlkGrubu) > 0 && field != "urunIlkGrubu" {
// Cascade scope: allow limiting options by the already selected "Urun Ilk Grubu" (desc).
// We filter by desc value because UI uses desc fields.
placeholders := make([]string, 0, len(scopeUrunIlkGrubu))
for _, v := range scopeUrunIlkGrubu {
v = strings.TrimSpace(v)
if v == "" {
continue
}
placeholders = append(placeholders, fmt.Sprintf("@p%d", len(args)+1))
args = append(args, v)
}
if len(placeholders) > 0 {
where = append(where, fmt.Sprintf("COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '') IN (%s)", strings.Join(placeholders, ", ")))
}
}
if q != "" {
// For productCode, allow contains if user types middle; for others use prefix.
if field == "productCode" {
where = append(where, expr+fmt.Sprintf(" LIKE @p%d", len(args)+1))
args = append(args, "%"+q+"%")
} else {
where = append(where, expr+fmt.Sprintf(" LIKE @p%d", len(args)+1))
args = append(args, q+"%")
}
}
whereSQL := strings.Join(where, " AND ")
sqlText := fmt.Sprintf(`
SELECT TOP (%d)
X.val
FROM (
SELECT DISTINCT NULLIF(%s, '') AS val
FROM ProductFilterWithDescription('TR')
WHERE %s
) X
WHERE X.val IS NOT NULL
ORDER BY X.val ASC;
`, limit, expr, whereSQL)
rows, err := mssql.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, limit)
for rows.Next() {
var v sql.NullString
if err := rows.Scan(&v); err != nil {
return nil, err
}
if s := strings.TrimSpace(v.String); s != "" {
out = append(out, s)
}
}
return out, rows.Err()
}