Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-06-17 21:56:49 +03:00
parent e1e9d4baf1
commit e14c1c176a
34 changed files with 7402 additions and 704 deletions

View File

@@ -805,6 +805,41 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "view",
wrapV3(http.HandlerFunc(routes.ExportAllProductPricingHandler)),
)
bindV3(r, pgDB,
"/api/pricing/products/price-list/export-excel", "POST",
"pricing", "view",
wrapV3(http.HandlerFunc(routes.ExportProductPriceListExcelHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/pricing/products/price-list/export-pdf", "POST",
"pricing", "view",
wrapV3(http.HandlerFunc(routes.ExportProductPriceListPDFHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/pricing/products/calculate-snapshots", "POST",
"pricing", "update",
wrapV3(routes.PostProductPricingCalculateSnapshotsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/products/{code}/price-history", "GET",
"pricing", "view",
wrapV3(routes.GetProductPricingHistoryHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/products/{code}/price-history/delete-latest", "POST",
"pricing", "update",
wrapV3(routes.PostDeleteLatestProductPriceHistoryHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/products/{code}/price-history/delete-selected", "POST",
"pricing", "update",
wrapV3(routes.PostDeleteSelectedProductPriceHistoryHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/products/save", "POST",
"pricing", "update",
wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)),
)
bindV3(r, pgDB,
"/api/pricing/brand-classification/lookups", "GET",
"pricing", "view",
@@ -830,6 +865,16 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "update",
wrapV3(routes.SetBrandGroupsBulkHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/brand-group-currency", "GET",
"pricing", "view",
wrapV3(routes.GetBrandGroupCurrencyHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/brand-group-currency/bulk-save", "POST",
"pricing", "update",
wrapV3(routes.SaveBrandGroupCurrencyHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/pricing/pricing-rules", "GET",
"pricing", "view",
@@ -1162,6 +1207,9 @@ func main() {
if err := queries.EnsurePricingParameterTables(pgDB); err != nil {
log.Println("mk_urunpricingprmtr bootstrap failed:", err)
}
if err := queries.EnsurePricingCalcInfraTables(pgDB); err != nil {
log.Println("pricing calc infra bootstrap failed:", err)
}
// -------------------------------------------------------
// ✉️ MAILER INIT
@@ -1184,6 +1232,8 @@ func main() {
startTranslationSyncScheduler(pgDB, db.MssqlDB)
startBrandSyncScheduler(pgDB, db.MssqlDB)
startPricingParameterSyncScheduler(pgDB, db.MssqlDB)
startProductPricingFxDeltaScheduler(pgDB)
startProductPricingFxFullScheduler(pgDB)
handler := enableCORS(
middlewares.GlobalAuthMiddleware(

View File

@@ -0,0 +1,123 @@
package main
import (
"bssapp-backend/db"
"bssapp-backend/queries"
"context"
"database/sql"
"log"
"os"
"strconv"
"strings"
"sync/atomic"
"time"
)
func startProductPricingFxDeltaScheduler(pgDB *sql.DB) {
enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_PRICING_FX_DELTA_ENABLED")))
if enabled == "0" || enabled == "false" || enabled == "off" {
log.Println("Product pricing FX delta scheduler disabled")
return
}
if pgDB == nil {
return
}
intervalMin := 1
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_DELTA_INTERVAL_MIN")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 1 {
intervalMin = parsed
}
}
batchSize := 200
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_DELTA_BATCH_SIZE")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 10 && parsed <= 2000 {
batchSize = parsed
}
}
var running int32 = 0
runOnce := func(reason string) {
if db.PgDB == nil {
return
}
if !atomic.CompareAndSwapInt32(&running, 0, 1) {
log.Printf("[PricingFxDelta] skip (%s): already running", reason)
return
}
defer atomic.StoreInt32(&running, 0)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
totalClaimed := 0
totalWritten := 0
for {
// Claim a batch.
tx, err := pgDB.BeginTx(ctx, nil)
if err != nil {
log.Printf("[PricingFxDelta] begin_tx_error (%s): %v", reason, err)
return
}
items, err := queries.ClaimPriceRecalcQueue(ctx, tx, batchSize)
if err != nil {
_ = tx.Rollback()
log.Printf("[PricingFxDelta] claim_error (%s): %v", reason, err)
return
}
if err := tx.Commit(); err != nil {
log.Printf("[PricingFxDelta] claim_commit_error (%s): %v", reason, err)
return
}
if len(items) == 0 {
break
}
totalClaimed += len(items)
codes := make([]string, 0, len(items))
for _, it := range items {
if it.ProductCode != "" {
codes = append(codes, it.ProductCode)
}
}
written, _, err := queries.PublishDerivedPricesFromAnchor(ctx, pgDB, codes, "", false)
if err != nil {
// Mark all failed.
tx2, _ := pgDB.BeginTx(ctx, nil)
if tx2 != nil {
for _, it := range items {
_ = queries.MarkPriceRecalcQueueFailed(ctx, tx2, it.ID, it.Attempts, err.Error())
}
_ = tx2.Commit()
}
log.Printf("[PricingFxDelta] publish_error (%s): claimed=%d err=%v", reason, len(items), err)
return
}
totalWritten += written
// Mark all done (even if some were skipped due to missing anchor).
tx3, _ := pgDB.BeginTx(ctx, nil)
if tx3 != nil {
for _, it := range items {
_ = queries.MarkPriceRecalcQueueDone(ctx, tx3, it.ID)
}
_ = tx3.Commit()
}
}
log.Printf("[PricingFxDelta] ok (%s): claimed=%d sdprc_written=%d interval_min=%d batch_size=%d", reason, totalClaimed, totalWritten, intervalMin, batchSize)
}
go func() {
time.Sleep(2 * time.Second)
runOnce("startup")
ticker := time.NewTicker(time.Duration(intervalMin) * time.Minute)
defer ticker.Stop()
for range ticker.C {
runOnce("scheduled")
}
}()
}

View File

@@ -0,0 +1,148 @@
package main
import (
"bssapp-backend/db"
"bssapp-backend/queries"
"context"
"database/sql"
"log"
"os"
"strconv"
"strings"
"sync/atomic"
"time"
)
// Weekly full FX publish job:
// - Runs once every Monday at a configured local time.
// - Recomputes derived currencies from anchor tiers and writes to sdprc for all products in mk_price_snapshot.
func startProductPricingFxFullScheduler(pgDB *sql.DB) {
enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_PRICING_FX_FULL_ENABLED")))
// Be conservative: require explicit opt-in.
if enabled != "1" && enabled != "true" && enabled != "on" && enabled != "yes" {
log.Println("Product pricing FX full scheduler disabled (set PRODUCT_PRICING_FX_FULL_ENABLED=1 to enable)")
return
}
if pgDB == nil {
return
}
// Default: Monday 06:00 local time.
runHH := 6
runMM := 0
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_FULL_HHMM")); raw != "" {
parts := strings.Split(raw, ":")
if len(parts) == 2 {
if h, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && h >= 0 && h <= 23 {
runHH = h
}
if m, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && m >= 0 && m <= 59 {
runMM = m
}
}
}
codeBatch := 1000
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_FULL_CODE_BATCH")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n >= 100 && n <= 5000 {
codeBatch = n
}
}
var running int32 = 0
runOnce := func(reason string) {
if db.PgDB == nil {
return
}
if !atomic.CompareAndSwapInt32(&running, 0, 1) {
log.Printf("[PricingFxFull] skip (%s): already running", reason)
return
}
defer atomic.StoreInt32(&running, 0)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour)
defer cancel()
totalCodes := 0
totalWritten := 0
totalSkipped := 0
lastCode := ""
for {
rows, err := pgDB.QueryContext(ctx, `
SELECT product_code
FROM mk_price_snapshot
WHERE COALESCE(NULLIF(BTRIM(product_code), ''), '') <> ''
AND product_code > $1
GROUP BY product_code
ORDER BY product_code
LIMIT $2
`, lastCode, codeBatch)
if err != nil {
log.Printf("[PricingFxFull] list_codes_error (%s): %v", reason, err)
return
}
codes := make([]string, 0, codeBatch)
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
_ = rows.Close()
log.Printf("[PricingFxFull] scan_code_error (%s): %v", reason, err)
return
}
c = strings.TrimSpace(c)
if c != "" {
codes = append(codes, c)
}
}
_ = rows.Close()
if len(codes) == 0 {
break
}
lastCode = codes[len(codes)-1]
// Force FX refresh on the weekly run so Monday picks up the latest rates.
written, skipped, err := queries.PublishDerivedPricesFromAnchor(ctx, pgDB, codes, "", true)
if err != nil {
log.Printf("[PricingFxFull] publish_error (%s): codes=%d err=%v", reason, len(codes), err)
return
}
totalCodes += len(codes)
totalWritten += written
totalSkipped += skipped
}
log.Printf("[PricingFxFull] ok (%s): products=%d sdprc_written=%d skipped=%d weekday=%d hhmm=%02d:%02d",
reason, totalCodes, totalWritten, totalSkipped, int(time.Now().Weekday()), runHH, runMM)
}
nextRun := func(now time.Time) time.Time {
loc := now.Location()
base := time.Date(now.Year(), now.Month(), now.Day(), runHH, runMM, 0, 0, loc)
daysUntilMon := (int(time.Monday) - int(now.Weekday()) + 7) % 7
candidate := base.AddDate(0, 0, daysUntilMon)
// If today is Monday but the time has passed, schedule next Monday.
if !candidate.After(now) {
candidate = candidate.AddDate(0, 0, 7)
}
return candidate
}
go func() {
time.Sleep(2 * time.Second)
for {
now := time.Now()
n := nextRun(now)
d := time.Until(n)
if d < 0 {
d = time.Minute
}
log.Printf("[PricingFxFull] scheduled next_at=%s in=%s", n.Format(time.RFC3339), d.Round(time.Second))
time.Sleep(d)
runOnce("weekly")
}
}()
}

View File

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

View File

@@ -0,0 +1,679 @@
package queries
import (
"bssapp-backend/db"
"bssapp-backend/models"
"context"
"crypto/md5"
"database/sql"
"encoding/hex"
"fmt"
"math"
"sort"
"strings"
"time"
)
type PricingFxRateCacheRow struct {
RateDate string `json:"rate_date"`
UsdTry float64 `json:"usd_try"`
EurTry float64 `json:"eur_try"`
UsdEur float64 `json:"usd_eur"`
}
type ProductPricingSnapshotCalcRequest struct {
ProductCodes []string
Filters ProductPricingFilters
RateDate string
ForceFxRefresh bool
}
type ProductPricingSnapshotCalcResult struct {
RateDate string `json:"rate_date"`
UsdTry float64 `json:"usd_try"`
EurTry float64 `json:"eur_try"`
UsdEur float64 `json:"usd_eur"`
Requested int `json:"requested"`
Calculated int `json:"calculated"`
Skipped int `json:"skipped"`
}
type ProductPricingSnapshotPreviewRow struct {
ProductCode string `json:"product_code"`
AnchorMode string `json:"anchor_mode"`
BasePriceUsd float64 `json:"base_price_usd"`
BasePriceTry float64 `json:"base_price_try"`
USD1 float64 `json:"usd1"`
USD2 float64 `json:"usd2"`
USD3 float64 `json:"usd3"`
USD4 float64 `json:"usd4"`
USD5 float64 `json:"usd5"`
USD6 float64 `json:"usd6"`
EUR1 float64 `json:"eur1"`
EUR2 float64 `json:"eur2"`
EUR3 float64 `json:"eur3"`
EUR4 float64 `json:"eur4"`
EUR5 float64 `json:"eur5"`
EUR6 float64 `json:"eur6"`
TRY1 float64 `json:"try1"`
TRY2 float64 `json:"try2"`
TRY3 float64 `json:"try3"`
TRY4 float64 `json:"try4"`
TRY5 float64 `json:"try5"`
TRY6 float64 `json:"try6"`
}
type ProductPricingSnapshotPreviewResult struct {
RateDate string `json:"rate_date"`
UsdTry float64 `json:"usd_try"`
EurTry float64 `json:"eur_try"`
UsdEur float64 `json:"usd_eur"`
Requested int `json:"requested"`
Calculated int `json:"calculated"`
Skipped int `json:"skipped"`
Rows []ProductPricingSnapshotPreviewRow `json:"rows"`
}
func resolvePricingFxRateByDate(ctx context.Context, pg *sql.DB, rateDate string, forceRefresh bool, persist bool) (PricingFxRateCacheRow, error) {
var out PricingFxRateCacheRow
rateDate = normalizeCalcDate(rateDate)
if rateDate == "" {
rateDate = time.Now().Format("2006-01-02")
}
if !forceRefresh {
err := pg.QueryRowContext(ctx, `
SELECT
TO_CHAR(rate_date, 'YYYY-MM-DD'),
usd_try::float8,
eur_try::float8,
usd_eur::float8
FROM mk_fx_rate_cache
WHERE rate_date=$1::date
`, rateDate).Scan(&out.RateDate, &out.UsdTry, &out.EurTry, &out.UsdEur)
if err == nil {
return out, nil
}
if err != nil && err != sql.ErrNoRows {
return out, err
}
}
if db.MssqlDB == nil {
return out, fmt.Errorf("mssql pricing db not available")
}
row, err := GetProductionHasCostDetailExchangeRatesByDate(ctx, db.MssqlDB, rateDate)
if err != nil {
return out, err
}
var (
rateDateResolved string
usdTry float64
eurTry float64
gbpIgnored float64
)
if err := row.Scan(&rateDateResolved, &usdTry, &eurTry, &gbpIgnored); err != nil {
return out, err
}
rateDateResolved = normalizeCalcDate(rateDateResolved)
if rateDateResolved == "" {
rateDateResolved = rateDate
}
usdEur := 0.0
if usdTry > 0 && eurTry > 0 {
usdEur = roundCalcValue(usdTry / eurTry)
}
if persist {
if _, err := pg.ExecContext(ctx, `
INSERT INTO mk_fx_rate_cache (
rate_date, usd_try, eur_try, usd_eur, source_system, source_updated_at, created_at, updated_at
)
VALUES ($1::date, $2, $3, $4, 'MSSQL', now(), now(), now())
ON CONFLICT (rate_date)
DO UPDATE SET
usd_try=EXCLUDED.usd_try,
eur_try=EXCLUDED.eur_try,
usd_eur=EXCLUDED.usd_eur,
source_system=EXCLUDED.source_system,
source_updated_at=EXCLUDED.source_updated_at,
updated_at=now()
`, rateDateResolved, usdTry, eurTry, usdEur); err != nil {
return out, err
}
}
out = PricingFxRateCacheRow{
RateDate: rateDateResolved,
UsdTry: usdTry,
EurTry: eurTry,
UsdEur: usdEur,
}
return out, nil
}
func SyncPricingFxRateCacheByDate(ctx context.Context, pg *sql.DB, rateDate string, forceRefresh bool) (PricingFxRateCacheRow, error) {
return resolvePricingFxRateByDate(ctx, pg, rateDate, forceRefresh, true)
}
func CalculateProductPricingSnapshots(ctx context.Context, pg *sql.DB, req ProductPricingSnapshotCalcRequest) (ProductPricingSnapshotCalcResult, error) {
var result ProductPricingSnapshotCalcResult
rateRow, err := resolvePricingFxRateByDate(ctx, pg, req.RateDate, req.ForceFxRefresh, true)
if err != nil {
return result, err
}
result.RateDate = rateRow.RateDate
result.UsdTry = rateRow.UsdTry
result.EurTry = rateRow.EurTry
result.UsdEur = rateRow.UsdEur
if rateRow.UsdTry <= 0 || rateRow.EurTry <= 0 || rateRow.UsdEur <= 0 {
return result, fmt.Errorf("invalid fx rates for date %s", rateRow.RateDate)
}
filters := req.Filters
if len(req.ProductCodes) > 0 {
filters.ProductCode = dedupeTrimmedStrings(req.ProductCodes)
}
rows, err := GetAllProductPricingRows(ctx, 1000, filters, "productCode", false)
if err != nil {
return result, err
}
result.Requested = len(rows)
if len(rows) == 0 {
return result, nil
}
ruleRows, err := ListPricingParameterRules(ctx, pg, PricingRuleOptionFilters{})
if err != nil {
return result, err
}
rulesByScope := make(map[string]PricingParameterRuleRow, len(ruleRows))
for _, item := range ruleRows {
rulesByScope[item.ScopeKey] = item
}
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
return result, err
}
defer tx.Rollback()
for _, product := range rows {
scopeKey := pricingParameterScopeKey(pricingParameterRow{
AskiliYan: strings.TrimSpace(product.AskiliYan),
Kategori: strings.TrimSpace(product.Kategori),
UrunIlkGrubu: strings.TrimSpace(product.UrunIlkGrubu),
UrunAnaGrubu: strings.TrimSpace(product.UrunAnaGrubu),
UrunAltGrubu: strings.TrimSpace(product.UrunAltGrubu),
Icerik: strings.TrimSpace(product.Icerik),
Marka: strings.TrimSpace(product.Marka),
BrandCode: strings.TrimSpace(product.BrandCode),
BrandGroupSec: strings.TrimSpace(product.BrandGroupSec),
})
ruleItem, ok := rulesByScope[scopeKey]
if !ok || ruleItem.Rule == nil {
result.Skipped++
continue
}
if !ruleItem.Rule.CalcEnabled || !ruleItem.Rule.IsActive {
result.Skipped++
continue
}
snapshot, ok := buildPricingSnapshotRow(product, ruleItem, rateRow)
if !ok {
result.Skipped++
continue
}
if err := upsertPricingSnapshot(ctx, tx, snapshot); err != nil {
return result, err
}
result.Calculated++
}
if err := tx.Commit(); err != nil {
return result, err
}
return result, nil
}
func PreviewProductPricingSnapshots(ctx context.Context, pg *sql.DB, req ProductPricingSnapshotCalcRequest) (ProductPricingSnapshotPreviewResult, error) {
var result ProductPricingSnapshotPreviewResult
rateRow, err := resolvePricingFxRateByDate(ctx, pg, req.RateDate, req.ForceFxRefresh, false)
if err != nil {
return result, err
}
result.RateDate = rateRow.RateDate
result.UsdTry = rateRow.UsdTry
result.EurTry = rateRow.EurTry
result.UsdEur = rateRow.UsdEur
if rateRow.UsdTry <= 0 || rateRow.EurTry <= 0 || rateRow.UsdEur <= 0 {
return result, fmt.Errorf("invalid fx rates for date %s", rateRow.RateDate)
}
filters := req.Filters
if len(req.ProductCodes) > 0 {
filters.ProductCode = dedupeTrimmedStrings(req.ProductCodes)
}
rows, err := GetAllProductPricingRows(ctx, 1000, filters, "productCode", false)
if err != nil {
return result, err
}
result.Requested = len(rows)
if len(rows) == 0 {
result.Rows = []ProductPricingSnapshotPreviewRow{}
return result, nil
}
ruleRows, err := ListPricingParameterRules(ctx, pg, PricingRuleOptionFilters{})
if err != nil {
return result, err
}
rulesByScope := make(map[string]PricingParameterRuleRow, len(ruleRows))
for _, item := range ruleRows {
rulesByScope[item.ScopeKey] = item
}
outRows := make([]ProductPricingSnapshotPreviewRow, 0, len(rows))
for _, product := range rows {
scopeKey := pricingParameterScopeKey(pricingParameterRow{
AskiliYan: strings.TrimSpace(product.AskiliYan),
Kategori: strings.TrimSpace(product.Kategori),
UrunIlkGrubu: strings.TrimSpace(product.UrunIlkGrubu),
UrunAnaGrubu: strings.TrimSpace(product.UrunAnaGrubu),
UrunAltGrubu: strings.TrimSpace(product.UrunAltGrubu),
Icerik: strings.TrimSpace(product.Icerik),
Marka: strings.TrimSpace(product.Marka),
BrandCode: strings.TrimSpace(product.BrandCode),
BrandGroupSec: strings.TrimSpace(product.BrandGroupSec),
})
ruleItem, ok := rulesByScope[scopeKey]
if !ok || ruleItem.Rule == nil {
result.Skipped++
continue
}
if !ruleItem.Rule.CalcEnabled || !ruleItem.Rule.IsActive {
result.Skipped++
continue
}
snapshot, ok := buildPricingSnapshotRow(product, ruleItem, rateRow)
if !ok {
result.Skipped++
continue
}
outRows = append(outRows, previewRowFromSnapshot(snapshot))
result.Calculated++
}
result.Rows = outRows
return result, nil
}
type pricingSnapshotRow struct {
ProductCode string
PricingParameterID int64
RuleID string
StrategyCode string
AnchorMode string
FxDate string
CostDate string
BasePriceTry float64
BasePriceUsd float64
Try [6]float64
Usd [6]float64
Eur [6]float64
CalcHash string
}
func buildPricingSnapshotRow(product models.ProductPricing, ruleItem PricingParameterRuleRow, fx PricingFxRateCacheRow) (pricingSnapshotRow, bool) {
var out pricingSnapshotRow
rule := ruleItem.Rule
if rule == nil {
return out, false
}
anchorMode := strings.ToUpper(strings.TrimSpace(rule.AnchorMode))
if anchorMode != "TRY" && anchorMode != "USD" {
anchorMode = "USD"
}
strategyCode := strings.ToUpper(strings.TrimSpace(rule.StrategyCode))
if strategyCode != "CORE" && strategyCode != "PREMIUM" && strategyCode != "SARTORIAL" {
strategyCode = strings.ToUpper(strings.TrimSpace(product.BrandGroupSec))
}
if strategyCode != "CORE" && strategyCode != "PREMIUM" && strategyCode != "SARTORIAL" {
strategyCode = "CORE"
}
costUSD := roundCalcValue(product.CostPrice)
if costUSD <= 0 {
return out, false
}
baseUSD := 0.0
baseTRY := 0.0
switch anchorMode {
case "TRY":
if rule.TryBase > 0 {
baseTRY = roundCalcValue(costUSD * fx.UsdTry * rule.TryBase)
} else if product.BasePriceTry > 0 {
baseTRY = roundCalcValue(product.BasePriceTry)
} else if product.BasePriceUsd > 0 {
baseTRY = roundCalcValue(product.BasePriceUsd * fx.UsdTry)
} else if rule.UsdBase > 0 {
baseTRY = roundCalcValue(costUSD * rule.UsdBase * fx.UsdTry)
}
if baseTRY <= 0 {
return out, false
}
baseUSD = roundCalcValue(baseTRY / fx.UsdTry)
default:
if rule.UsdBase > 0 {
baseUSD = roundCalcValue(costUSD * rule.UsdBase)
} else if product.BasePriceUsd > 0 {
baseUSD = roundCalcValue(product.BasePriceUsd)
} else if product.BasePriceTry > 0 {
baseUSD = roundCalcValue(product.BasePriceTry / fx.UsdTry)
}
if baseUSD <= 0 {
return out, false
}
baseTRY = roundCalcValue(baseUSD * fx.UsdTry)
}
baseEUR := roundCalcValue(baseUSD * fx.UsdEur)
tryBaseForCalc := baseTRY
usdBaseForCalc := baseUSD
eurBaseForCalc := baseEUR
if tryBaseForCalc <= 0 || usdBaseForCalc <= 0 || eurBaseForCalc <= 0 {
return out, false
}
tryMultipliers := [6]float64{rule.Try1, rule.Try2, rule.Try3, rule.Try4, rule.Try5, rule.Try6}
usdMultipliers := [6]float64{rule.Usd1, rule.Usd2, rule.Usd3, rule.Usd4, rule.Usd5, rule.Usd6}
eurMultipliers := [6]float64{rule.Eur1, rule.Eur2, rule.Eur3, rule.Eur4, rule.Eur5, rule.Eur6}
prevTry := tryBaseForCalc
prevUsd := usdBaseForCalc
prevEur := eurBaseForCalc
for i := 0; i < 6; i++ {
tryRaw := prevTry * tryMultipliers[i]
usdRaw := prevUsd * usdMultipliers[i]
eurRaw := prevEur * eurMultipliers[i]
tryStep := rule.TryWholesaleStep
usdStep := rule.UsdWholesaleStep
eurStep := rule.EurWholesaleStep
if i == 5 {
out.Try[i] = applyRetailRounding(tryRaw, rule.TryWholesaleStep, rule.TryRetailStep, rule.TryRetailMode)
out.Usd[i] = applyRetailRounding(usdRaw, rule.UsdWholesaleStep, rule.UsdRetailStep, rule.UsdRetailMode)
out.Eur[i] = applyRetailRounding(eurRaw, rule.EurWholesaleStep, rule.EurRetailStep, rule.EurRetailMode)
prevTry = out.Try[i]
prevUsd = out.Usd[i]
prevEur = out.Eur[i]
continue
}
out.Try[i] = roundUpStep(tryRaw, tryStep)
out.Usd[i] = roundUpStep(usdRaw, usdStep)
out.Eur[i] = roundUpStep(eurRaw, eurStep)
prevTry = out.Try[i]
prevUsd = out.Usd[i]
prevEur = out.Eur[i]
}
out.ProductCode = strings.TrimSpace(product.ProductCode)
out.PricingParameterID = ruleItem.PricingParameterID
out.RuleID = strings.TrimSpace(rule.ID)
out.StrategyCode = strategyCode
out.AnchorMode = anchorMode
out.FxDate = fx.RateDate
out.CostDate = normalizeCalcDate(product.LastCostingDate)
out.BasePriceTry = baseTRY
out.BasePriceUsd = baseUSD
out.CalcHash = pricingSnapshotHash(out, fx)
return out, true
}
func applyRetailRounding(raw, wholesaleStep, retailStep float64, retailMode string) float64 {
baseRounded := roundUpStep(raw, wholesaleStep)
mode := normalizeRetailMode(retailMode)
switch mode {
case "END_99":
return roundUpToEnding(baseRounded, 99)
case "END_49":
return roundUpToEnding(baseRounded, 49)
case "BAND_99":
return roundUpToBandEnding(baseRounded, retailStep, 99)
case "BAND_49":
return roundUpToBandEnding(baseRounded, retailStep, 49)
default:
if retailStep > 0 {
return roundUpStep(baseRounded, retailStep)
}
return baseRounded
}
}
func roundUpToEnding(value float64, ending int) float64 {
value = roundCalcValue(value)
if value <= 0 {
return 0
}
switch ending {
case 99:
return roundCalcValue(psychologicalEnding99(value))
case 49:
return roundCalcValue(psychologicalEnding49(value))
default:
whole := math.Floor(value + 1e-9)
candidate := whole + (float64(ending) / 100.0)
if candidate+1e-9 < value {
candidate = whole + 1 + (float64(ending) / 100.0)
}
return roundCalcValue(candidate)
}
}
func roundUpToBandEnding(value, band float64, ending int) float64 {
value = roundCalcValue(value)
band = roundCalcValue(band)
if value <= 0 {
return 0
}
if band <= 0 {
return roundUpToEnding(value, ending)
}
units := math.Ceil((value - 1e-9) / band)
candidate := (units * band) - 1 + (float64(ending) / 100.0)
if candidate+1e-9 < value {
candidate = ((units + 1) * band) - 1 + (float64(ending) / 100.0)
}
return roundCalcValue(candidate)
}
func psychologicalEnding99(value float64) float64 {
whole := math.Floor(value + 1e-9)
fraction := value - whole
if fraction >= 0.90 {
return whole + 0.99
}
return whole - 0.01
}
func psychologicalEnding49(value float64) float64 {
whole := math.Floor(value + 1e-9)
fraction := value - whole
if fraction >= 0.40 {
return whole + 0.49
}
return whole - 0.51
}
func upsertPricingSnapshot(ctx context.Context, tx *sql.Tx, row pricingSnapshotRow) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO mk_price_snapshot (
product_code, pricing_parameter_id, rule_id, strategy_code, anchor_mode, fx_date, cost_date,
base_price_try, base_price_usd,
try1, try2, try3, try4, try5, try6,
usd1, usd2, usd3, usd4, usd5, usd6,
eur1, eur2, eur3, eur4, eur5, eur6,
calc_hash, created_at, updated_at
)
VALUES (
$1,$2,NULLIF($3,'')::uuid,$4,$5,$6::date,NULLIF($7,'')::date,
$8,$9,
$10,$11,$12,$13,$14,$15,
$16,$17,$18,$19,$20,$21,
$22,$23,$24,$25,$26,$27,
$28,now(),now()
)
ON CONFLICT (product_code, pricing_parameter_id)
DO UPDATE SET
rule_id=NULLIF(EXCLUDED.rule_id::text,'')::uuid,
strategy_code=EXCLUDED.strategy_code,
anchor_mode=EXCLUDED.anchor_mode,
fx_date=EXCLUDED.fx_date,
cost_date=EXCLUDED.cost_date,
base_price_try=EXCLUDED.base_price_try,
base_price_usd=EXCLUDED.base_price_usd,
try1=EXCLUDED.try1,
try2=EXCLUDED.try2,
try3=EXCLUDED.try3,
try4=EXCLUDED.try4,
try5=EXCLUDED.try5,
try6=EXCLUDED.try6,
usd1=EXCLUDED.usd1,
usd2=EXCLUDED.usd2,
usd3=EXCLUDED.usd3,
usd4=EXCLUDED.usd4,
usd5=EXCLUDED.usd5,
usd6=EXCLUDED.usd6,
eur1=EXCLUDED.eur1,
eur2=EXCLUDED.eur2,
eur3=EXCLUDED.eur3,
eur4=EXCLUDED.eur4,
eur5=EXCLUDED.eur5,
eur6=EXCLUDED.eur6,
calc_hash=EXCLUDED.calc_hash,
updated_at=now()
`, row.ProductCode, row.PricingParameterID, row.RuleID, row.StrategyCode, row.AnchorMode, row.FxDate, row.CostDate,
row.BasePriceTry, row.BasePriceUsd,
row.Try[0], row.Try[1], row.Try[2], row.Try[3], row.Try[4], row.Try[5],
row.Usd[0], row.Usd[1], row.Usd[2], row.Usd[3], row.Usd[4], row.Usd[5],
row.Eur[0], row.Eur[1], row.Eur[2], row.Eur[3], row.Eur[4], row.Eur[5],
row.CalcHash,
)
return err
}
func previewRowFromSnapshot(row pricingSnapshotRow) ProductPricingSnapshotPreviewRow {
return ProductPricingSnapshotPreviewRow{
ProductCode: row.ProductCode,
AnchorMode: row.AnchorMode,
BasePriceUsd: roundCalcValue(row.BasePriceUsd),
BasePriceTry: roundCalcValue(row.BasePriceTry),
USD1: roundCalcValue(row.Usd[0]),
USD2: roundCalcValue(row.Usd[1]),
USD3: roundCalcValue(row.Usd[2]),
USD4: roundCalcValue(row.Usd[3]),
USD5: roundCalcValue(row.Usd[4]),
USD6: roundCalcValue(row.Usd[5]),
EUR1: roundCalcValue(row.Eur[0]),
EUR2: roundCalcValue(row.Eur[1]),
EUR3: roundCalcValue(row.Eur[2]),
EUR4: roundCalcValue(row.Eur[3]),
EUR5: roundCalcValue(row.Eur[4]),
EUR6: roundCalcValue(row.Eur[5]),
TRY1: roundCalcValue(row.Try[0]),
TRY2: roundCalcValue(row.Try[1]),
TRY3: roundCalcValue(row.Try[2]),
TRY4: roundCalcValue(row.Try[3]),
TRY5: roundCalcValue(row.Try[4]),
TRY6: roundCalcValue(row.Try[5]),
}
}
func roundUpStep(value, step float64) float64 {
value = roundCalcValue(value)
if value <= 0 {
return 0
}
step = roundCalcValue(step)
if step <= 0 {
return value
}
units := math.Ceil((value - 1e-9) / step)
return roundCalcValue(units * step)
}
func roundCalcValue(value float64) float64 {
if !isFiniteCalc(value) {
return 0
}
return math.Round(value*1_000_000) / 1_000_000
}
func isFiniteCalc(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
func normalizeCalcDate(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if len(value) >= 10 {
value = value[:10]
}
if _, err := time.Parse("2006-01-02", value); err != nil {
return ""
}
return value
}
func dedupeTrimmedStrings(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, raw := range values {
val := strings.TrimSpace(raw)
if val == "" {
continue
}
if _, ok := seen[val]; ok {
continue
}
seen[val] = struct{}{}
out = append(out, val)
}
sort.Strings(out)
return out
}
func pricingSnapshotHash(row pricingSnapshotRow, fx PricingFxRateCacheRow) string {
parts := []string{
row.ProductCode,
fmt.Sprintf("%d", row.PricingParameterID),
row.RuleID,
row.StrategyCode,
row.AnchorMode,
row.FxDate,
row.CostDate,
fmt.Sprintf("%.6f", row.BasePriceTry),
fmt.Sprintf("%.6f", row.BasePriceUsd),
fmt.Sprintf("%.6f", fx.UsdTry),
fmt.Sprintf("%.6f", fx.EurTry),
fmt.Sprintf("%.6f", fx.UsdEur),
}
for _, value := range row.Try {
parts = append(parts, fmt.Sprintf("%.6f", value))
}
for _, value := range row.Usd {
parts = append(parts, fmt.Sprintf("%.6f", value))
}
for _, value := range row.Eur {
parts = append(parts, fmt.Sprintf("%.6f", value))
}
sum := md5.Sum([]byte(strings.Join(parts, string(rune(31)))))
return hex.EncodeToString(sum[:])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,362 @@
package queries
import (
"bssapp-backend/models"
"context"
"database/sql"
"encoding/json"
"fmt"
"math"
"strings"
)
type FxDeltaPublishStats struct {
RateDate string
Queued int
Updated int // sdprc rows updated/inserted
Skipped int // missing anchor or rule
Failures int
}
type sdprcPublishRow struct {
ProductCode string `json:"product_code"`
Currency string `json:"currency"`
LevelNo int `json:"level_no"`
Price float64 `json:"price"`
}
func round2fx(v float64) float64 {
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0
}
return math.Round(v*100) / 100
}
func roundDerivedWithRule(rule *PricingRuleRow, currency string, level int, raw float64) float64 {
currency = strings.ToUpper(strings.TrimSpace(currency))
if level < 1 || level > 6 {
return 0
}
if rule == nil {
// Fallback: keep a stable 2-decimal behavior when no rule exists.
return round2fx(raw)
}
whStep := 0.0
rtStep := 0.0
rtMode := ""
switch currency {
case "TRY":
whStep, rtStep, rtMode = rule.TryWholesaleStep, rule.TryRetailStep, rule.TryRetailMode
case "USD":
whStep, rtStep, rtMode = rule.UsdWholesaleStep, rule.UsdRetailStep, rule.UsdRetailMode
case "EUR":
whStep, rtStep, rtMode = rule.EurWholesaleStep, rule.EurRetailStep, rule.EurRetailMode
default:
return 0
}
// In our model: level 1-5 = wholesale rounding, level 6 = retail rounding.
if level >= 6 {
return applyRetailRounding(raw, whStep, rtStep, rtMode)
}
return roundUpStep(raw, whStep)
}
// PublishDerivedPricesFromAnchor recalculates derived currency tiers from the stored anchor tiers in sdprc.
// Rule selection determines anchor_mode (USD/TRY). Anchor tiers are never modified here.
func PublishDerivedPricesFromAnchor(ctx context.Context, pg *sql.DB, productCodes []string, rateDate string, forceFxRefresh bool) (int, int, error) {
if len(productCodes) == 0 {
return 0, 0, nil
}
rateRow, err := resolvePricingFxRateByDate(ctx, pg, rateDate, forceFxRefresh, true)
if err != nil {
return 0, 0, err
}
if rateRow.UsdTry <= 0 || rateRow.EurTry <= 0 || rateRow.UsdEur <= 0 {
return 0, 0, fmt.Errorf("invalid fx rates for date %s", rateRow.RateDate)
}
// Load rule map once.
ruleRows, err := ListPricingParameterRules(ctx, pg, PricingRuleOptionFilters{})
if err != nil {
return 0, 0, err
}
rulesByScope := make(map[string]PricingParameterRuleRow, len(ruleRows))
for _, item := range ruleRows {
rulesByScope[item.ScopeKey] = item
}
// Fetch product metadata (scope) from MSSQL.
products, err := GetAllProductPricingRows(ctx, 1000, ProductPricingFilters{ProductCode: productCodes}, "productCode", false)
if err != nil {
return 0, 0, err
}
byCode := make(map[string]models.ProductPricing, len(products))
for _, p := range products {
code := strings.TrimSpace(p.ProductCode)
if code == "" {
continue
}
byCode[code] = p
}
derivedTargets := make([]sdprcPublishRow, 0, len(productCodes)*12) // derived: 2 currencies * 6 levels
skipped := 0
for _, codeRaw := range productCodes {
code := strings.TrimSpace(codeRaw)
if code == "" {
continue
}
product, ok := byCode[code]
if !ok {
skipped++
continue
}
scopeKey := pricingParameterScopeKey(pricingParameterRow{
AskiliYan: strings.TrimSpace(product.AskiliYan),
Kategori: strings.TrimSpace(product.Kategori),
UrunIlkGrubu: strings.TrimSpace(product.UrunIlkGrubu),
UrunAnaGrubu: strings.TrimSpace(product.UrunAnaGrubu),
UrunAltGrubu: strings.TrimSpace(product.UrunAltGrubu),
Icerik: strings.TrimSpace(product.Icerik),
Marka: strings.TrimSpace(product.Marka),
BrandCode: strings.TrimSpace(product.BrandCode),
BrandGroupSec: strings.TrimSpace(product.BrandGroupSec),
})
ruleItem, ok := rulesByScope[scopeKey]
if !ok || ruleItem.Rule == nil || !ruleItem.Rule.IsActive || !ruleItem.Rule.CalcEnabled {
skipped++
continue
}
anchorMode := strings.ToUpper(strings.TrimSpace(ruleItem.Rule.AnchorMode))
if anchorMode != "USD" && anchorMode != "TRY" {
anchorMode = "USD"
}
anchor, ok, err := loadLatestSdprcTiers(ctx, pg, code, anchorMode)
if err != nil || !ok {
skipped++
continue
}
switch anchorMode {
case "USD":
for i := 0; i < 6; i++ {
level := i + 1
usd := anchor[i]
tryV := roundDerivedWithRule(ruleItem.Rule, "TRY", level, usd*rateRow.UsdTry)
eurV := roundDerivedWithRule(ruleItem.Rule, "EUR", level, usd*rateRow.UsdEur)
if tryV > 0 {
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "TRY", LevelNo: level, Price: tryV})
}
if eurV > 0 {
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "EUR", LevelNo: level, Price: eurV})
}
}
default: // TRY
for i := 0; i < 6; i++ {
level := i + 1
tryV := anchor[i]
usd := roundDerivedWithRule(ruleItem.Rule, "USD", level, tryV/rateRow.UsdTry)
eurV := roundDerivedWithRule(ruleItem.Rule, "EUR", level, tryV/rateRow.EurTry)
if usd > 0 {
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "USD", LevelNo: level, Price: usd})
}
if eurV > 0 {
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "EUR", LevelNo: level, Price: eurV})
}
}
}
}
if len(derivedTargets) == 0 {
return 0, skipped, nil
}
written, err := bulkUpsertSdprcDerived(ctx, pg, derivedTargets)
return written, skipped, err
}
func loadLatestSdprcTiers(ctx context.Context, pg *sql.DB, productCode string, currency string) ([6]float64, bool, error) {
var out [6]float64
productCode = strings.TrimSpace(productCode)
currency = strings.ToUpper(strings.TrimSpace(currency))
if productCode == "" {
return out, false, nil
}
if currency != "USD" && currency != "TRY" {
return out, false, nil
}
rows, err := pg.QueryContext(ctx, `
WITH latest AS (
SELECT DISTINCT ON (sdprc.sdprcgrp_id)
sdprc.sdprcgrp_id AS grp,
COALESCE(sdprc.prc, 0)::float8 AS prc
FROM sdprc
JOIN mmitem ON mmitem.id = sdprc.mmitem_id
WHERE mmitem.code = $1
AND sdprc.crn = $2
AND sdprc.sdprcgrp_id BETWEEN 1 AND 6
AND sdprc.prc IS NOT NULL
AND sdprc.prc > 0
ORDER BY sdprc.sdprcgrp_id, sdprc.zlins_dttm DESC
)
SELECT grp, prc FROM latest ORDER BY grp;
`, productCode, currency)
if err != nil {
return out, false, err
}
defer rows.Close()
found := 0
for rows.Next() {
var grp int
var prc float64
if err := rows.Scan(&grp, &prc); err != nil {
return out, false, err
}
if grp >= 1 && grp <= 6 && prc > 0 {
out[grp-1] = prc
found++
}
}
if err := rows.Err(); err != nil {
return out, false, err
}
return out, found == 6, nil
}
func bulkUpsertSdprcDerived(ctx context.Context, pg *sql.DB, targets []sdprcPublishRow) (int, error) {
raw, err := json.Marshal(targets)
if err != nil {
return 0, err
}
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(2003, 1)`); err != nil {
return 0, err
}
q := `
WITH input AS (
SELECT *
FROM jsonb_to_recordset($1::jsonb) AS x(product_code text, currency text, level_no int, price float8)
),
norm AS (
SELECT
NULLIF(BTRIM(product_code), '') AS product_code,
UPPER(NULLIF(BTRIM(currency), '')) AS currency,
COALESCE(level_no, 0) AS level_no,
COALESCE(price, 0) AS price
FROM input
),
dims_cache AS (
SELECT
NULLIF(BTRIM(c.product_code), '') AS product_code,
c.dim1,
c.dim3
FROM mk_mmitem_dim_combo c
JOIN norm
ON norm.product_code = c.product_code
WHERE c.dim1 IS NOT NULL
),
dims_sdprc AS (
SELECT
norm.product_code AS product_code,
s.dim1 AS dim1,
s.dim3 AS dim3
FROM norm
JOIN mmitem mm
ON mm.code = norm.product_code
JOIN sdprc s
ON s.mmitem_id = mm.id
WHERE s.dim1 IS NOT NULL
AND s.dim1 > 0
GROUP BY norm.product_code, s.dim1, s.dim3
),
dims AS (
SELECT product_code, dim1, dim3 FROM dims_cache
UNION
SELECT product_code, dim1, dim3 FROM dims_sdprc
),
mapped AS (
SELECT
mm.id AS mmitem_id,
m.sdprcgrp_id AS sdprcgrp_id,
norm.currency AS crn,
d.dim1 AS dim1,
d.dim3 AS dim3,
norm.price AS prc
FROM norm
JOIN dims d
ON d.product_code = norm.product_code
JOIN mk_price_target_map_pg m
ON m.is_active = TRUE
AND m.currency = norm.currency
AND m.level_no = norm.level_no
JOIN mmitem mm
ON mm.code = norm.product_code
WHERE norm.product_code IS NOT NULL
AND norm.currency IN ('USD','EUR','TRY')
AND norm.level_no BETWEEN 1 AND 6
AND norm.price > 0
AND m.sdprcgrp_id IS NOT NULL
),
latest AS (
SELECT DISTINCT ON (s.mmitem_id, s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0))
s.id,
s.mmitem_id,
s.sdprcgrp_id,
s.crn,
s.dim1,
s.dim3
FROM sdprc s
JOIN mapped m
ON m.mmitem_id = s.mmitem_id
AND m.sdprcgrp_id = s.sdprcgrp_id
AND m.crn = s.crn
AND m.dim1 = s.dim1
AND ((m.dim3 IS NULL AND s.dim3 IS NULL) OR (m.dim3 = s.dim3))
ORDER BY s.mmitem_id, s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0), s.zlins_dttm DESC
),
updated AS (
UPDATE sdprc s
SET prc = m.prc,
zlins_dttm = now()
FROM latest l
JOIN mapped m
ON m.mmitem_id=l.mmitem_id AND m.sdprcgrp_id=l.sdprcgrp_id AND m.crn=l.crn
AND m.dim1 = l.dim1 AND ((m.dim3 IS NULL AND l.dim3 IS NULL) OR (m.dim3 = l.dim3))
WHERE s.id = l.id
AND s.prc IS DISTINCT FROM m.prc
RETURNING 1
),
inserted AS (
INSERT INTO sdprc (mmitem_id, sdprcgrp_id, crn, dim1, dim3, prc, zlins_dttm)
SELECT m.mmitem_id, m.sdprcgrp_id, m.crn, m.dim1, m.dim3, m.prc, now()
FROM mapped m
LEFT JOIN latest l
ON l.mmitem_id=m.mmitem_id AND l.sdprcgrp_id=m.sdprcgrp_id AND l.crn=m.crn
AND l.dim1 = m.dim1 AND ((l.dim3 IS NULL AND m.dim3 IS NULL) OR (l.dim3 = m.dim3))
WHERE l.id IS NULL
RETURNING 1
)
SELECT (SELECT COUNT(*) FROM updated)::int + (SELECT COUNT(*) FROM inserted)::int;
`
var written int
if err := tx.QueryRowContext(ctx, q, raw).Scan(&written); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return written, nil
}

View File

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

View File

@@ -0,0 +1,97 @@
package routes
import (
"bssapp-backend/queries"
"bssapp-backend/utils"
"database/sql"
"encoding/json"
"net/http"
"strings"
)
type BrandGroupCurrencyItem struct {
ID int `json:"id"`
AnchorMode string `json:"anchor_mode"`
}
type BrandGroupCurrencyPayload struct {
Items []BrandGroupCurrencyItem `json:"items"`
}
func GetBrandGroupCurrencyHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := queries.EnsureBrandClassificationTables(pg); err != nil {
http.Error(w, "brand tables bootstrap error", http.StatusInternalServerError)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
rows, err := queries.ListBrandGroups(ctx, pg)
if err != nil {
http.Error(w, "brand group currency list error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(rows)
}
}
func SaveBrandGroupCurrencyHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := queries.EnsureBrandClassificationTables(pg); err != nil {
http.Error(w, "brand tables bootstrap error", http.StatusInternalServerError)
return
}
var payload BrandGroupCurrencyPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if len(payload.Items) == 0 {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "updated": 0})
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "pg transaction start error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
updated := 0
for _, item := range payload.Items {
if item.ID <= 0 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
mode := strings.ToUpper(strings.TrimSpace(item.AnchorMode))
if mode != "TRY" && mode != "USD" {
http.Error(w, "invalid anchor_mode", http.StatusBadRequest)
return
}
if err := queries.SetBrandGroupAnchorMode(ctx, tx, item.ID, mode); err != nil {
http.Error(w, "brand group currency save error", http.StatusInternalServerError)
return
}
if err := queries.SyncPricingRuleAnchorModesByGroup(ctx, tx, item.ID, mode); err != nil {
http.Error(w, "pricing rule anchor sync error", http.StatusInternalServerError)
return
}
updated++
}
if err := tx.Commit(); err != nil {
http.Error(w, "pg transaction commit error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "updated": updated})
}
}

View File

@@ -1,11 +1,13 @@
package routes
import (
"bssapp-backend/auth"
"bssapp-backend/queries"
"bssapp-backend/utils"
"database/sql"
"encoding/json"
"fmt"
"github.com/lib/pq"
"net/http"
"sort"
"strconv"
@@ -33,6 +35,11 @@ type PricingRuleImportItem struct {
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"`
@@ -43,6 +50,7 @@ type PricingRuleImportItem struct {
Try6 float64 `json:"try6"`
TryWholesaleStep float64 `json:"try_wholesale_step"`
TryRetailStep float64 `json:"try_retail_step"`
TryRetailMode string `json:"try_retail_mode"`
UsdBase float64 `json:"usd_base"`
Usd1 float64 `json:"usd1"`
Usd2 float64 `json:"usd2"`
@@ -52,6 +60,7 @@ type PricingRuleImportItem struct {
Usd6 float64 `json:"usd6"`
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
UsdRetailStep float64 `json:"usd_retail_step"`
UsdRetailMode string `json:"usd_retail_mode"`
EurBase float64 `json:"eur_base"`
Eur1 float64 `json:"eur1"`
Eur2 float64 `json:"eur2"`
@@ -61,6 +70,7 @@ type PricingRuleImportItem struct {
Eur6 float64 `json:"eur6"`
EurWholesaleStep float64 `json:"eur_wholesale_step"`
EurRetailStep float64 `json:"eur_retail_step"`
EurRetailMode string `json:"eur_retail_mode"`
}
type PricingRuleImportPayload struct {
@@ -77,6 +87,52 @@ type PricingRuleImportResult struct {
ErrorCount int `json:"error_count"`
}
func normalizePricingStrategyCode(v string) string {
v = strings.ToUpper(strings.TrimSpace(v))
if v == "" {
return "CORE"
}
return v
}
func normalizePricingAnchorMode(v string) string {
v = strings.ToUpper(strings.TrimSpace(v))
if v == "" {
return "USD"
}
return v
}
func isValidPricingStrategyCode(v string) bool {
if strings.TrimSpace(v) == "" {
return true
}
switch normalizePricingStrategyCode(v) {
case "CORE", "PREMIUM", "SARTORIAL":
return true
default:
return false
}
}
func isValidPricingAnchorMode(v string) bool {
switch normalizePricingAnchorMode(v) {
case "TRY", "USD":
return true
default:
return false
}
}
func isValidPricingRetailMode(v string) bool {
switch queries.NormalizeRetailModeForRoute(v) {
case "STEP", "END_99", "END_49", "BAND_99", "BAND_49":
return true
default:
return false
}
}
func GetPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -104,37 +160,130 @@ func SavePricingRulesBulkHandler(pg *sql.DB) http.HandlerFunc {
return
}
started := time.Now()
traceID := utils.TraceIDFromRequest(r)
w.Header().Set("X-Trace-ID", traceID)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "pricing-rules.bulk-save")
claims, _ := auth.GetClaimsFromContext(ctx)
if claims != nil {
logger = logger.With("user", claims.Username, "user_id", claims.ID)
}
existingIDCount := 0
newIDCount := 0
for _, it := range payload.Items {
if strings.TrimSpace(it.ID) != "" {
existingIDCount++
} else {
newIDCount++
}
}
logger.Info("bulk-save:start",
"items", len(payload.Items),
"existing_id", existingIDCount,
"new_id", newIDCount,
)
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
logger.Error("bulk-save:tx-begin:error", "err", err)
http.Error(w, "pg transaction start error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Serialize writes touching mk_urunpricingprmtr/mk_pricing_rule/mk_pricex/mk_priceroll
// to avoid deadlocks with pricing-parameter sync and concurrent bulk-saves.
lockWaitStarted := time.Now()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil {
logger.Error("bulk-save:advisory-lock:error", "err", err)
http.Error(w, "pg advisory lock error", http.StatusInternalServerError)
return
}
logger.Info("bulk-save:advisory-lock:acquired", "wait_ms", time.Since(lockWaitStarted).Milliseconds())
logPgErr := func(msg string, err error, it queries.PricingRuleSaveItem) {
fields := []any{
"pricing_parameter_id", it.PricingParameterID,
"id", strings.TrimSpace(it.ID),
"err", err,
}
if pe, ok := err.(*pq.Error); ok && pe != nil {
fields = append(fields,
"sqlstate", string(pe.Code),
"constraint", pe.Constraint,
"table", pe.Table,
"column", pe.Column,
"detail", pe.Detail,
"where", pe.Where,
)
}
logger.Error(msg, fields...)
}
updated := 0
for _, it := range payload.Items {
// Zero means that no rounding rule has been configured yet.
if it.TryWholesaleStep < 0 || it.TryRetailStep < 0 || it.UsdWholesaleStep < 0 || it.UsdRetailStep < 0 || it.EurWholesaleStep < 0 || it.EurRetailStep < 0 {
logger.Warn("bulk-save:invalid-rounding-step",
"pricing_parameter_id", it.PricingParameterID,
"id", strings.TrimSpace(it.ID),
)
http.Error(w, "invalid rounding step", http.StatusBadRequest)
return
}
id, err := queries.UpsertPricingRule(ctx, tx, it)
if err != nil {
http.Error(w, "pricing rule save error", http.StatusInternalServerError)
if !isValidPricingStrategyCode(it.StrategyCode) {
logger.Warn("bulk-save:invalid-strategy-code",
"pricing_parameter_id", it.PricingParameterID,
"id", strings.TrimSpace(it.ID),
"strategy_code", it.StrategyCode,
)
http.Error(w, "invalid strategy_code", http.StatusBadRequest)
return
}
if id != "" {
updated++
if !isValidPricingAnchorMode(it.AnchorMode) {
logger.Warn("bulk-save:invalid-anchor-mode",
"pricing_parameter_id", it.PricingParameterID,
"id", strings.TrimSpace(it.ID),
"anchor_mode", it.AnchorMode,
)
http.Error(w, "invalid anchor_mode", http.StatusBadRequest)
return
}
if !isValidPricingRetailMode(it.TryRetailMode) || !isValidPricingRetailMode(it.UsdRetailMode) || !isValidPricingRetailMode(it.EurRetailMode) {
logger.Warn("bulk-save:invalid-retail-mode",
"pricing_parameter_id", it.PricingParameterID,
"id", strings.TrimSpace(it.ID),
"try_retail_mode", it.TryRetailMode,
"usd_retail_mode", it.UsdRetailMode,
"eur_retail_mode", it.EurRetailMode,
)
http.Error(w, "invalid retail_mode", http.StatusBadRequest)
return
}
}
dbStarted := time.Now()
updated, err = queries.BulkSavePricingRulesFast(ctx, tx, payload.Items)
if err != nil {
// best-effort: log first item context
if len(payload.Items) > 0 {
logPgErr("bulk-save:bulk-fast:error", err, payload.Items[0])
} else {
logger.Error("bulk-save:bulk-fast:error", "err", err)
}
http.Error(w, "pricing rule save error: "+err.Error(), http.StatusInternalServerError)
return
}
logger.Info("bulk-save:db:done", "updated", updated, "duration_ms", time.Since(dbStarted).Milliseconds())
if err := tx.Commit(); err != nil {
logger.Error("bulk-save:commit:error", "err", err)
http.Error(w, "pg transaction commit error", http.StatusInternalServerError)
return
}
logger.Info("bulk-save:done", "updated", updated, "duration_ms", time.Since(started).Milliseconds())
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "updated": updated})
}
}
@@ -163,6 +312,12 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
}
defer tx.Rollback()
// Same global lock as bulk-save: prevents deadlocks with concurrent updates/sync.
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil {
http.Error(w, "pg advisory lock error", http.StatusInternalServerError)
return
}
updated := 0
matched := 0
skipped := 0
@@ -171,6 +326,18 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
http.Error(w, "invalid rounding step", http.StatusBadRequest)
return
}
if !isValidPricingStrategyCode(raw.StrategyCode) {
http.Error(w, "invalid strategy_code", http.StatusBadRequest)
return
}
if !isValidPricingAnchorMode(raw.AnchorMode) {
http.Error(w, "invalid anchor_mode", http.StatusBadRequest)
return
}
if !isValidPricingRetailMode(raw.TryRetailMode) || !isValidPricingRetailMode(raw.UsdRetailMode) || !isValidPricingRetailMode(raw.EurRetailMode) {
http.Error(w, "invalid retail_mode", http.StatusBadRequest)
return
}
pricingParameterID, err := queries.FindActivePricingParameterByScope(ctx, tx, queries.PricingParameterRowForImport(
raw.AskiliYan,
@@ -195,6 +362,11 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
_, err = queries.UpsertPricingRule(ctx, tx, queries.PricingRuleSaveItem{
PricingParameterID: pricingParameterID,
StrategyCode: normalizePricingStrategyCode(raw.StrategyCode),
AnchorMode: normalizePricingAnchorMode(raw.AnchorMode),
CalcEnabled: raw.CalcEnabled,
PublishPostgres: raw.PublishPostgres,
PublishNebim: raw.PublishNebim,
IsActive: raw.IsActive,
TryBase: raw.TryBase,
Try1: raw.Try1,
@@ -205,6 +377,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
Try6: raw.Try6,
TryWholesaleStep: raw.TryWholesaleStep,
TryRetailStep: raw.TryRetailStep,
TryRetailMode: queries.NormalizeRetailModeForRoute(raw.TryRetailMode),
UsdBase: raw.UsdBase,
Usd1: raw.Usd1,
Usd2: raw.Usd2,
@@ -214,6 +387,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
Usd6: raw.Usd6,
UsdWholesaleStep: raw.UsdWholesaleStep,
UsdRetailStep: raw.UsdRetailStep,
UsdRetailMode: queries.NormalizeRetailModeForRoute(raw.UsdRetailMode),
EurBase: raw.EurBase,
Eur1: raw.Eur1,
Eur2: raw.Eur2,
@@ -223,6 +397,7 @@ func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc {
Eur6: raw.Eur6,
EurWholesaleStep: raw.EurWholesaleStep,
EurRetailStep: raw.EurRetailStep,
EurRetailMode: queries.NormalizeRetailModeForRoute(raw.EurRetailMode),
})
if err != nil {
http.Error(w, "pricing rule import error", http.StatusInternalServerError)
@@ -470,7 +645,34 @@ func sortPricingRuleExportRows(rows []queries.PricingParameterRuleRow, sortBy st
return boolRank(liActive) > boolRank(ljActive)
}
return boolRank(liActive) < boolRank(ljActive)
case "askili_yan", "kategori", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "icerik", "marka", "brand_code", "brand_group":
case "calc_enabled", "publish_postgres", "publish_nebim":
liValue, ljValue := false, false
if li.Rule != nil {
switch sortBy {
case "calc_enabled":
liValue = li.Rule.CalcEnabled
case "publish_postgres":
liValue = li.Rule.PublishPostgres
case "publish_nebim":
liValue = li.Rule.PublishNebim
}
}
if lj.Rule != nil {
switch sortBy {
case "calc_enabled":
ljValue = lj.Rule.CalcEnabled
case "publish_postgres":
ljValue = lj.Rule.PublishPostgres
case "publish_nebim":
ljValue = lj.Rule.PublishNebim
}
}
if desc {
return boolRank(liValue) > boolRank(ljValue)
}
return boolRank(liValue) < boolRank(ljValue)
case "askili_yan", "kategori", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "icerik", "marka", "brand_code", "brand_group", "anchor_mode",
"try_retail_mode", "usd_retail_mode", "eur_retail_mode":
vi := pricingRuleStringValue(li, sortBy)
vj := pricingRuleStringValue(lj, sortBy)
if desc {
@@ -515,6 +717,26 @@ func pricingRuleStringValue(row queries.PricingParameterRuleRow, field string) s
return row.BrandCode
case "brand_group":
return row.BrandGroupSec
case "anchor_mode":
if row.Rule == nil {
return "USD"
}
return row.Rule.AnchorMode
case "try_retail_mode":
if row.Rule == nil {
return "STEP"
}
return queries.NormalizeRetailModeForRoute(row.Rule.TryRetailMode)
case "usd_retail_mode":
if row.Rule == nil {
return "STEP"
}
return queries.NormalizeRetailModeForRoute(row.Rule.UsdRetailMode)
case "eur_retail_mode":
if row.Rule == nil {
return "STEP"
}
return queries.NormalizeRetailModeForRoute(row.Rule.EurRetailMode)
default:
return ""
}
@@ -523,10 +745,10 @@ func pricingRuleStringValue(row queries.PricingParameterRuleRow, field string) s
func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
headers := []string{
"DURUM", "AKTIF", "ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU",
"ICERIK", "MARKA", "BRAND CODE", "MARKA GRUBU",
"TRY TOPTAN YUVARLAMA", "TRY PERAKENDE YUVARLAMA", "TRY TABAN", "TRY 1", "TRY 2", "TRY 3", "TRY 4", "TRY 5", "TRY 6",
"USD TOPTAN YUVARLAMA", "USD PERAKENDE YUVARLAMA", "USD TABAN", "USD 1", "USD 2", "USD 3", "USD 4", "USD 5", "USD 6",
"EUR TOPTAN YUVARLAMA", "EUR PERAKENDE YUVARLAMA", "EUR TABAN", "EUR 1", "EUR 2", "EUR 3", "EUR 4", "EUR 5", "EUR 6",
"ICERIK", "MARKA", "BRAND CODE", "MARKA GRUBU", "ANCHOR MODE", "HESAP AKTIF", "PG YAYIN", "NEBIM YAYIN",
"TRY TOPTAN YUVARLAMA", "TRY PERAKENDE MODU", "TRY PERAKENDE DEGERI", "TRY TABAN", "TRY 1", "TRY 2", "TRY 3", "TRY 4", "TRY 5", "TRY 6",
"USD TOPTAN YUVARLAMA", "USD PERAKENDE MODU", "USD PERAKENDE DEGERI", "USD TABAN", "USD 1", "USD 2", "USD 3", "USD 4", "USD 5", "USD 6",
"EUR TOPTAN YUVARLAMA", "EUR PERAKENDE MODU", "EUR PERAKENDE DEGERI", "EUR TABAN", "EUR 1", "EUR 2", "EUR 3", "EUR 4", "EUR 5", "EUR 6",
}
var b strings.Builder
for i, h := range headers {
@@ -551,10 +773,15 @@ func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
row.UrunAnaGrubu,
row.UrunAltGrubu,
row.Icerik,
row.Marka,
csvExcelTextValue(row.Marka),
csvExcelTextValue(row.BrandCode),
row.BrandGroupSec,
pricingRuleStringValue(row, "anchor_mode"),
map[bool]string{true: "Aktif", false: "Pasif"}[row.Rule == nil || row.Rule.CalcEnabled],
map[bool]string{true: "Evet", false: "Hayir"}[row.Rule == nil || row.Rule.PublishPostgres],
map[bool]string{true: "Evet", false: "Hayir"}[row.Rule == nil || row.Rule.PublishNebim],
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_wholesale_step")),
pricingRuleStringValue(row, "try_retail_mode"),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_retail_step")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_base")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try1")),
@@ -564,6 +791,7 @@ func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try5")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try6")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_wholesale_step")),
pricingRuleStringValue(row, "usd_retail_mode"),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_retail_step")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_base")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd1")),
@@ -573,6 +801,7 @@ func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string {
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd5")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd6")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_wholesale_step")),
pricingRuleStringValue(row, "eur_retail_mode"),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_retail_step")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_base")),
fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur1")),

View File

@@ -0,0 +1,107 @@
package routes
import (
"bssapp-backend/auth"
"bssapp-backend/queries"
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"strings"
"time"
)
type productPricingCalcRequest struct {
ProductCodes []string `json:"product_codes"`
RateDate string `json:"rate_date"`
ForceFxRefresh bool `json:"force_fx_refresh"`
PreviewOnly bool `json:"preview_only"`
Search string `json:"q"`
ProductCode []string `json:"product_code"`
BrandGroup []string `json:"brand_group_selection"`
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"`
}
func PostProductPricingCalculateSnapshotsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
traceID := buildPricingTraceID(r)
w.Header().Set("X-Trace-ID", traceID)
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 170*time.Second)
defer cancel()
reqBody := productPricingCalcRequest{}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&reqBody)
}
filters := queries.ProductPricingFilters{
Search: strings.TrimSpace(reqBody.Search),
ProductCode: reqBody.ProductCode,
BrandGroup: reqBody.BrandGroup,
AskiliYan: reqBody.AskiliYan,
Kategori: reqBody.Kategori,
UrunIlkGrubu: reqBody.UrunIlkGrubu,
UrunAnaGrubu: reqBody.UrunAnaGrubu,
UrunAltGrubu: reqBody.UrunAltGrubu,
Icerik: reqBody.Icerik,
Karisim: reqBody.Karisim,
Marka: reqBody.Marka,
}
if filters.Search == "" && len(filters.ProductCode) == 0 && len(filters.BrandGroup) == 0 &&
len(filters.AskiliYan) == 0 && len(filters.Kategori) == 0 && len(filters.UrunIlkGrubu) == 0 &&
len(filters.UrunAnaGrubu) == 0 && len(filters.UrunAltGrubu) == 0 && len(filters.Icerik) == 0 &&
len(filters.Karisim) == 0 && len(filters.Marka) == 0 {
filters = parseProductPricingFilters(r)
}
calcReq := queries.ProductPricingSnapshotCalcRequest{
ProductCodes: reqBody.ProductCodes,
Filters: filters,
RateDate: reqBody.RateDate,
ForceFxRefresh: reqBody.ForceFxRefresh,
}
if reqBody.PreviewOnly {
result, err := queries.PreviewProductPricingSnapshots(ctx, pg, calcReq)
if err != nil {
log.Printf("[ProductPricingCalcPreview] trace=%s user=%s id=%d err=%v duration_ms=%d",
traceID, claims.Username, claims.ID, err, time.Since(started).Milliseconds())
http.Error(w, "Urun fiyat hesap onizlemesi olusturulamadi: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[ProductPricingCalcPreview] trace=%s user=%s id=%d requested=%d calculated=%d skipped=%d fx_date=%s duration_ms=%d",
traceID, claims.Username, claims.ID, result.Requested, result.Calculated, result.Skipped, result.RateDate, time.Since(started).Milliseconds())
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(result)
return
}
result, err := queries.CalculateProductPricingSnapshots(ctx, pg, calcReq)
if err != nil {
log.Printf("[ProductPricingCalc] trace=%s user=%s id=%d err=%v duration_ms=%d",
traceID, claims.Username, claims.ID, err, time.Since(started).Milliseconds())
http.Error(w, "Urun fiyat hesaplari olusturulamadi: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[ProductPricingCalc] trace=%s user=%s id=%d requested=%d calculated=%d skipped=%d fx_date=%s duration_ms=%d",
traceID, claims.Username, claims.ID, result.Requested, result.Calculated, result.Skipped, result.RateDate, time.Since(started).Milliseconds())
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(result)
}
}

View File

@@ -0,0 +1,265 @@
package routes
import (
"context"
"database/sql"
"fmt"
"log"
"sort"
"strings"
"time"
"bssapp-backend/db"
"bssapp-backend/internal/mailer"
"bssapp-backend/models"
"bssapp-backend/queries"
)
func loadPricingRecipients(pg *sql.DB, firstGroupCode string) ([]string, error) {
rows, err := pg.Query(`
SELECT DISTINCT TRIM(m.email) AS email
FROM mk_pricing_first_group_mail f
JOIN mk_mail m
ON m.id = f.mail_id
WHERE m.is_active = true
AND COALESCE(TRIM(m.email), '') <> ''
AND UPPER(TRIM(f.urun_ilk_grubu)) = UPPER(TRIM($1))
ORDER BY email
`, strings.TrimSpace(firstGroupCode))
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, 16)
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, err
}
email = strings.TrimSpace(email)
if email != "" {
out = append(out, email)
}
}
return out, rows.Err()
}
func htmlEscapeMini(s string) string {
// Minimal safe escaping for our templated cells.
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&#39;",
)
return r.Replace(s)
}
func fmtMoneyMail(v float64) string { return fmt.Sprintf("%.2f", v) }
func fmtQtyMail(v float64) string { return fmt.Sprintf("%.2f", v) }
func fmtDateTRFromISO(d string) string {
d = strings.TrimSpace(d)
if len(d) >= 10 {
d = d[:10]
}
parts := strings.Split(d, "-")
if len(parts) != 3 {
if d == "" {
return "-"
}
return d
}
y, m, day := parts[0], parts[1], parts[2]
if y == "" || m == "" || day == "" {
return d
}
return day + "." + m + "." + y
}
func buildPricingChangeMailHTML(firstGroupCode string, rows []models.ProductPricing, actor string, at time.Time) string {
// Keep it simple: wide, scrollable table.
var b strings.Builder
// NOTE: Mail clients often render small fonts; keep this comfortably readable.
// Use large inline sizes (some clients still downscale); keep everything inline for maximum compatibility.
b.WriteString(`<div style="font-family:Segoe UI, Arial, sans-serif; font-size:18px; line-height:1.35; -webkit-text-size-adjust:100%;">`)
b.WriteString(`<div style="margin-bottom:10px;">`)
b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Fiyat Degisikligi</b></div>`)
b.WriteString(`<div>Urun Ilk Grubu: <b>` + htmlEscapeMini(firstGroupCode) + `</b></div>`)
if strings.TrimSpace(actor) != "" {
b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`)
}
b.WriteString(`<div>Tarih: <b>` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `</b></div>`)
b.WriteString(`<div>Urun Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`)
b.WriteString(`</div>`)
b.WriteString(`<div style="max-width:100%; overflow-x:auto;">`)
b.WriteString(`<table style="border-collapse:collapse; font-size:16px; white-space:nowrap;">`)
b.WriteString(`<thead><tr>`)
heads := []string{
"MARKA GRUBU", "MARKA", "BRAND CODE", "URUN KODU",
"STOK ADET", "STOK GIRIS", "SON MALIYET", "SON FIYAT",
"ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "KARISIM",
"MALIYET FIYATI", "TABAN USD", "TABAN TRY",
"USD1", "USD2", "USD3", "USD4", "USD5", "USD6",
"EUR1", "EUR2", "EUR3", "EUR4", "EUR5", "EUR6",
"TRY1", "TRY2", "TRY3", "TRY4", "TRY5", "TRY6",
}
for _, h := range heads {
b.WriteString(`<th style="border:1px solid #d0d0d0; background:#f3f3f3; padding:8px 10px; text-align:left; font-size:16px;">` + htmlEscapeMini(h) + `</th>`)
}
b.WriteString(`</tr></thead><tbody>`)
for _, r := range rows {
b.WriteString(`<tr>`)
cells := []string{
r.BrandGroupSec,
r.Marka,
r.BrandCode,
r.ProductCode,
fmtQtyMail(r.StockQty),
fmtDateTRFromISO(r.StockEntryDate),
fmtDateTRFromISO(r.LastCostingDate),
fmtDateTRFromISO(r.LastPricingDate),
r.AskiliYan,
r.Kategori,
r.UrunIlkGrubu,
r.UrunAnaGrubu,
r.UrunAltGrubu,
r.Icerik,
r.Karisim,
fmtMoneyMail(r.CostPrice),
fmtMoneyMail(r.BasePriceUsd),
fmtMoneyMail(r.BasePriceTry),
fmtMoneyMail(r.USD1), fmtMoneyMail(r.USD2), fmtMoneyMail(r.USD3), fmtMoneyMail(r.USD4), fmtMoneyMail(r.USD5), fmtMoneyMail(r.USD6),
fmtMoneyMail(r.EUR1), fmtMoneyMail(r.EUR2), fmtMoneyMail(r.EUR3), fmtMoneyMail(r.EUR4), fmtMoneyMail(r.EUR5), fmtMoneyMail(r.EUR6),
fmtMoneyMail(r.TRY1), fmtMoneyMail(r.TRY2), fmtMoneyMail(r.TRY3), fmtMoneyMail(r.TRY4), fmtMoneyMail(r.TRY5), fmtMoneyMail(r.TRY6),
}
for i, c := range cells {
align := "left"
// right align numeric-ish cells
if i >= 4 {
switch i {
case 4, 15, 16, 17,
18, 19, 20, 21, 22, 23,
24, 25, 26, 27, 28, 29,
30, 31, 32, 33, 34, 35:
align = "right"
}
}
b.WriteString(`<td style="border:1px solid #e0e0e0; padding:8px 10px; text-align:` + align + `;">` + htmlEscapeMini(strings.TrimSpace(c)) + `</td>`)
}
b.WriteString(`</tr>`)
}
b.WriteString(`</tbody></table></div>`)
b.WriteString(`<div style="margin-top:12px; font-size:14px; color:#666;">Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.</div>`)
b.WriteString(`</div>`)
return b.String()
}
// sendPricingChangeMails sends one mail per UrunIlkGrubu (group) based on mk_pricing_first_group_mail mapping.
// It is designed to be called post-commit in a goroutine.
func sendPricingChangeMails(bg context.Context, ml *mailer.GraphMailer, productCodes []string, actor string) {
if ml == nil {
return
}
pg := db.PgDB
if pg == nil {
log.Printf("[pricing-mail] skipped: pg not ready")
return
}
// Ensure mapping tables exist.
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
log.Printf("[pricing-mail] mapping bootstrap error: %v", err)
return
}
ctx, cancel := context.WithTimeout(bg, 90*time.Second)
defer cancel()
codes := make([]string, 0, len(productCodes))
seen := map[string]struct{}{}
for _, c := range productCodes {
c = strings.TrimSpace(c)
if c == "" {
continue
}
if _, ok := seen[c]; ok {
continue
}
seen[c] = struct{}{}
codes = append(codes, c)
}
if len(codes) == 0 {
return
}
rows, err := queries.GetAllProductPricingRows(ctx, 500, queries.ProductPricingFilters{ProductCode: codes}, "productCode", false)
if err != nil {
log.Printf("[pricing-mail] pricing rows query error: %v", err)
return
}
if len(rows) == 0 {
return
}
byGroup := map[string][]models.ProductPricing{}
for _, r := range rows {
g := strings.TrimSpace(r.UrunIlkGrubu)
if g == "" {
g = "UNKNOWN"
}
byGroup[g] = append(byGroup[g], r)
}
now := time.Now()
for group, list := range byGroup {
// No mapping = skip.
recipients, err := loadPricingRecipients(pg, group)
if err != nil {
log.Printf("[pricing-mail] recipient query error group=%s err=%v", group, err)
continue
}
if len(recipients) == 0 {
log.Printf("[pricing-mail] no recipients mapped group=%s", group)
continue
}
sort.Slice(list, func(i, j int) bool {
return strings.TrimSpace(list[i].ProductCode) < strings.TrimSpace(list[j].ProductCode)
})
subject := fmt.Sprintf("Fiyat Degisikligi | %s | %s | %d urun", group, now.Format("02.01.2006 15:04"), len(list))
html := buildPricingChangeMailHTML(group, list, actor, now)
// Retry 2 times with backoff.
backoff := []time.Duration{800 * time.Millisecond, 2500 * time.Millisecond}
var lastErr error
for attempt := 0; attempt < len(backoff)+1; attempt++ {
if attempt > 0 {
time.Sleep(backoff[attempt-1])
}
stepCtx, stepCancel := context.WithTimeout(bg, 25*time.Second)
err := ml.Send(stepCtx, mailer.Message{
To: recipients,
Subject: subject,
BodyHTML: html,
})
stepCancel()
if err == nil {
lastErr = nil
break
}
lastErr = err
}
if lastErr != nil {
log.Printf("[pricing-mail] send failed group=%s err=%v", group, lastErr)
} else {
log.Printf("[pricing-mail] sent group=%s to=%d products=%d", group, len(recipients), len(list))
}
}
}

View File

@@ -0,0 +1,505 @@
package routes
import (
"bssapp-backend/auth"
"bssapp-backend/db"
"bssapp-backend/queries"
"bssapp-backend/utils"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/lib/pq"
)
type productPricingHistoryPGRow struct {
ID string `json:"id"`
Currency string `json:"currency"`
LevelNo int `json:"level_no"`
Price float64 `json:"price"`
UpdatedAt string `json:"updated_at"`
SdprcGrpID int `json:"sdprcgrp_id"`
}
type productPricingHistoryMSSQLRow struct {
PriceListLineID string `json:"price_list_line_id"`
Currency string `json:"currency"`
PriceGroupCode string `json:"price_group_code"`
Price float64 `json:"price"`
ValidDate string `json:"valid_date"`
ValidTime string `json:"valid_time"`
LastUpdatedDate string `json:"last_updated_date"`
IsDisabled bool `json:"is_disabled"`
}
type productPricingHistoryResponse struct {
ProductCode string `json:"product_code"`
Postgres []productPricingHistoryPGRow `json:"postgres"`
Mssql []productPricingHistoryMSSQLRow `json:"mssql"`
}
func GetProductPricingHistoryHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
w.Header().Set("X-Trace-ID", traceID)
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
productCode := strings.TrimSpace(mux.Vars(r)["code"])
if productCode == "" {
http.Error(w, "product code required", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
defer cancel()
// Load nebim price groups from PG mapping (18) + base groups (2).
priceGroups := []string{"TM-USD", "TM-TRY"}
if pg != nil {
rows, err := pg.QueryContext(ctx, `
SELECT DISTINCT COALESCE(NULLIF(BTRIM(price_group_code), ''), '')
FROM mk_price_target_map_nebim
WHERE is_active = TRUE
`)
if err == nil {
for rows.Next() {
var code string
if err := rows.Scan(&code); err != nil {
_ = rows.Close()
break
}
code = strings.TrimSpace(code)
if code != "" {
priceGroups = append(priceGroups, code)
}
}
_ = rows.Close()
}
}
resp := productPricingHistoryResponse{
ProductCode: productCode,
Postgres: []productPricingHistoryPGRow{},
Mssql: []productPricingHistoryMSSQLRow{},
}
// Postgres sdprc history.
if pg != nil {
pgRows, err := pg.QueryContext(ctx, `
SELECT
sdprc.id::text,
sdprc.crn,
sdprc.sdprcgrp_id,
COALESCE(sdprc.prc, 0)::float8,
TO_CHAR(sdprc.zlins_dttm, 'YYYY-MM-DD HH24:MI:SS')
FROM sdprc
JOIN mmitem ON mmitem.id = sdprc.mmitem_id
WHERE mmitem.code = $1
AND sdprc.crn IN ('USD','EUR','TRY')
AND sdprc.sdprcgrp_id BETWEEN 1 AND 6
ORDER BY sdprc.zlins_dttm DESC
LIMIT 400;
`, productCode)
if err == nil {
for pgRows.Next() {
var id, cur, at string
var grp int
var prc float64
if err := pgRows.Scan(&id, &cur, &grp, &prc, &at); err != nil {
_ = pgRows.Close()
http.Error(w, "pg history scan error", http.StatusInternalServerError)
return
}
resp.Postgres = append(resp.Postgres, productPricingHistoryPGRow{
ID: strings.TrimSpace(id),
Currency: strings.ToUpper(strings.TrimSpace(cur)),
SdprcGrpID: grp,
LevelNo: grp,
Price: prc,
UpdatedAt: strings.TrimSpace(at),
})
}
_ = pgRows.Close()
}
}
// MSSQL trPriceListLine history (only relevant price groups).
mssql := db.GetDB()
if mssql != nil {
// Build a safe "IN" via OR parameters.
conds := make([]string, 0, len(priceGroups))
args := make([]any, 0, len(priceGroups)+1)
args = append(args, sql.Named("p1", productCode))
for i, g := range priceGroups {
name := fmt.Sprintf("g%d", i+1)
conds = append(conds, "LTRIM(RTRIM(p.PriceGroupCode)) = @"+name)
args = append(args, sql.Named(name, g))
}
wherePG := "1=0"
if len(conds) > 0 {
wherePG = "(" + strings.Join(conds, " OR ") + ")"
}
q := `
SELECT TOP (400)
CONVERT(NVARCHAR(36), p.PriceListLineID) AS PriceListLineID,
LTRIM(RTRIM(p.DocCurrencyCode)) AS DocCurrencyCode,
LTRIM(RTRIM(p.PriceGroupCode)) AS PriceGroupCode,
CAST(p.Price AS FLOAT) AS Price,
CONVERT(VARCHAR(10), p.ValidDate, 23) AS ValidDate,
CONVERT(VARCHAR(8), p.ValidTime, 108) AS ValidTime,
CONVERT(VARCHAR(19), p.LastUpdatedDate, 120) AS LastUpdatedDate,
CAST(ISNULL(p.IsDisabled, 0) AS BIT) AS IsDisabled
FROM dbo.trPriceListLine p WITH(NOLOCK)
WHERE p.ItemTypeCode = 1
AND LTRIM(RTRIM(p.ItemCode)) = @p1
AND ` + wherePG + `
ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC;
`
rows, err := mssql.QueryContext(ctx, q, args...)
if err == nil {
for rows.Next() {
var id, cur, grp, vd, vt, lud string
var prc float64
var disabled bool
if err := rows.Scan(&id, &cur, &grp, &prc, &vd, &vt, &lud, &disabled); err != nil {
_ = rows.Close()
http.Error(w, "mssql history scan error", http.StatusInternalServerError)
return
}
resp.Mssql = append(resp.Mssql, productPricingHistoryMSSQLRow{
PriceListLineID: strings.TrimSpace(id),
Currency: strings.ToUpper(strings.TrimSpace(cur)),
PriceGroupCode: strings.TrimSpace(grp),
Price: prc,
ValidDate: strings.TrimSpace(vd),
ValidTime: strings.TrimSpace(vt),
LastUpdatedDate: strings.TrimSpace(lud),
IsDisabled: disabled,
})
}
_ = rows.Close()
}
}
_ = json.NewEncoder(w).Encode(resp)
}
}
type deleteLatestPriceHistoryRequest struct {
DeletePostgres bool `json:"delete_postgres"`
DeleteMssql bool `json:"delete_mssql"`
Currency string `json:"currency"` // USD/EUR/TRY
LevelNo int `json:"level_no"` // 1..6 (tier); for base use 0
IsBase bool `json:"is_base"`
PriceGroupCode string `json:"price_group_code"` // optional override for MSSQL deletes
}
func PostDeleteLatestProductPriceHistoryHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
w.Header().Set("X-Trace-ID", traceID)
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
productCode := strings.TrimSpace(mux.Vars(r)["code"])
if productCode == "" {
http.Error(w, "product code required", http.StatusBadRequest)
return
}
var req deleteLatestPriceHistoryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if !req.DeletePostgres && !req.DeleteMssql {
req.DeletePostgres = true
req.DeleteMssql = true
}
cur := strings.ToUpper(strings.TrimSpace(req.Currency))
if cur != "USD" && cur != "EUR" && cur != "TRY" {
http.Error(w, "invalid currency", http.StatusBadRequest)
return
}
if !req.IsBase && req.DeletePostgres && (req.LevelNo < 1 || req.LevelNo > 6) {
http.Error(w, "invalid level_no", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
defer cancel()
// PG delete (sdprc).
deletedPG := int64(0)
if req.DeletePostgres && !req.IsBase && pg != nil {
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "pg tx error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
var mmItemID int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, productCode).Scan(&mmItemID); err != nil {
http.Error(w, "pg product not found", http.StatusNotFound)
return
}
grp := req.LevelNo
// Delete latest row for that currency+level.
res, err := tx.ExecContext(ctx, `
DELETE FROM sdprc
WHERE id = (
SELECT id
FROM sdprc
WHERE mmitem_id=$1 AND crn=$2 AND sdprcgrp_id=$3
ORDER BY zlins_dttm DESC
LIMIT 1
);
`, mmItemID, cur, grp)
if err != nil {
http.Error(w, "pg delete error", http.StatusInternalServerError)
return
}
deletedPG, _ = res.RowsAffected()
// enqueue delta recompute for this product to keep derived currencies consistent
_, _ = queries.EnqueuePriceRecalc(ctx, tx, []string{productCode}, "history_delete")
if err := tx.Commit(); err != nil {
http.Error(w, "pg commit error", http.StatusInternalServerError)
return
}
}
// MSSQL delete (trPriceListLine).
deletedMSSQL := int64(0)
if req.DeleteMssql {
mssql := db.GetDB()
if mssql == nil {
http.Error(w, "mssql not connected", http.StatusInternalServerError)
return
}
tx, err := mssql.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "mssql tx error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
priceGroup := strings.TrimSpace(req.PriceGroupCode)
if req.IsBase {
if cur == "USD" {
priceGroup = "TM-USD"
} else if cur == "TRY" {
priceGroup = "TM-TRY"
} else {
http.Error(w, "base only supports USD/TRY", http.StatusBadRequest)
return
}
} else if priceGroup == "" && pg != nil {
_ = pg.QueryRowContext(ctx, `
SELECT COALESCE(NULLIF(BTRIM(price_group_code), ''), '')
FROM mk_price_target_map_nebim
WHERE is_active=TRUE AND currency=$1 AND level_no=$2
`, cur, req.LevelNo).Scan(&priceGroup)
}
priceGroup = strings.TrimSpace(priceGroup)
if priceGroup == "" {
http.Error(w, "missing price group mapping", http.StatusBadRequest)
return
}
res, err := tx.ExecContext(ctx, `
;WITH latest AS (
SELECT TOP (1) p.PriceListLineID
FROM dbo.trPriceListLine p WITH(UPDLOCK, ROWLOCK)
WHERE p.ItemTypeCode=1
AND LTRIM(RTRIM(p.ItemCode))=@p1
AND LTRIM(RTRIM(p.DocCurrencyCode))=@p2
AND LTRIM(RTRIM(p.PriceGroupCode))=@p3
AND ISNULL(p.IsDisabled, 0)=0
ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC
)
DELETE FROM dbo.trPriceListLine
WHERE PriceListLineID IN (SELECT PriceListLineID FROM latest);
`, sql.Named("p1", productCode), sql.Named("p2", cur), sql.Named("p3", priceGroup))
if err != nil {
http.Error(w, "mssql delete error", http.StatusInternalServerError)
return
}
deletedMSSQL, _ = res.RowsAffected()
if err := tx.Commit(); err != nil {
http.Error(w, "mssql commit error", http.StatusInternalServerError)
return
}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"product_code": productCode,
"deleted_pg": deletedPG,
"deleted_mssql": deletedMSSQL,
"actor_user": claims.Username,
"actor_user_id": claims.ID,
})
}
}
type deleteSelectedPriceHistoryRequest struct {
PGIDs []string `json:"pg_ids"` // sdprc.id (uuid)
MSSQLIDs []string `json:"mssql_ids"` // trPriceListLine.PriceListLineID (uuid)
}
func PostDeleteSelectedProductPriceHistoryHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
w.Header().Set("X-Trace-ID", traceID)
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
productCode := strings.TrimSpace(mux.Vars(r)["code"])
if productCode == "" {
http.Error(w, "product code required", http.StatusBadRequest)
return
}
var req deleteSelectedPriceHistoryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
// normalize ids
pgIDs := make([]string, 0, len(req.PGIDs))
for _, x := range req.PGIDs {
s := strings.TrimSpace(x)
if s != "" {
pgIDs = append(pgIDs, s)
}
}
msIDs := make([]string, 0, len(req.MSSQLIDs))
for _, x := range req.MSSQLIDs {
s := strings.TrimSpace(x)
if s != "" {
msIDs = append(msIDs, s)
}
}
if len(pgIDs) == 0 && len(msIDs) == 0 {
http.Error(w, "no ids selected", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second)
defer cancel()
deletedPG := int64(0)
if len(pgIDs) > 0 && pg != nil {
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "pg tx error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Resolve product id to constrain deletes to the given productCode.
var mmItemID int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, productCode).Scan(&mmItemID); err != nil {
http.Error(w, "pg product not found", http.StatusNotFound)
return
}
// Delete only rows matching mmitem_id + id list.
res, err := tx.ExecContext(ctx, `
DELETE FROM sdprc
WHERE mmitem_id = $1
AND id = ANY($2::uuid[]);
`, mmItemID, pq.Array(pgIDs))
if err != nil {
http.Error(w, "pg delete error", http.StatusInternalServerError)
return
}
deletedPG, _ = res.RowsAffected()
_, _ = queries.EnqueuePriceRecalc(ctx, tx, []string{productCode}, "history_delete_selected")
if err := tx.Commit(); err != nil {
http.Error(w, "pg commit error", http.StatusInternalServerError)
return
}
}
deletedMSSQL := int64(0)
if len(msIDs) > 0 {
mssql := db.GetDB()
if mssql == nil {
http.Error(w, "mssql not connected", http.StatusInternalServerError)
return
}
tx, err := mssql.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "mssql tx error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Build a safe IN-list via named parameters.
placeholders := make([]string, 0, len(msIDs))
args := make([]any, 0, len(msIDs)+1)
args = append(args, sql.Named("p1", productCode))
for i, id := range msIDs {
name := fmt.Sprintf("id%d", i+1)
placeholders = append(placeholders, "@"+name)
args = append(args, sql.Named(name, id))
}
q := `
DELETE FROM dbo.trPriceListLine
WHERE ItemTypeCode = 1
AND LTRIM(RTRIM(ItemCode)) = @p1
AND PriceListLineID IN (` + strings.Join(placeholders, ",") + `);
`
res, err := tx.ExecContext(ctx, q, args...)
if err != nil {
http.Error(w, "mssql delete error", http.StatusInternalServerError)
return
}
deletedMSSQL, _ = res.RowsAffected()
if err := tx.Commit(); err != nil {
http.Error(w, "mssql commit error", http.StatusInternalServerError)
return
}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"product_code": productCode,
"deleted_pg": deletedPG,
"deleted_mssql": deletedMSSQL,
"actor_user": claims.Username,
"actor_user_id": claims.ID,
})
}
}

View File

@@ -0,0 +1,492 @@
package routes
import (
"bssapp-backend/auth"
"bssapp-backend/models"
"bssapp-backend/queries"
"bssapp-backend/utils"
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"math"
"net/http"
"sort"
"strings"
"time"
"github.com/jung-kurt/gofpdf"
"github.com/xuri/excelize/v2"
)
type priceListExportRequest struct {
// Product filters (same semantics as listing)
ProductCode []string `json:"product_code"`
BrandGroup []string `json:"brand_group"`
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"`
Search string `json:"search"`
InStockOnly bool `json:"in_stock_only"`
// Column selection
IncludeMeta bool `json:"include_meta"`
IncludeCost bool `json:"include_cost"`
IncludeBase bool `json:"include_base"`
USDLevels []int `json:"usd_levels"` // 1..6
EURLevels []int `json:"eur_levels"` // 1..6
TRYLevels []int `json:"try_levels"` // 1..6
}
type exportCol struct {
Key string
Title string
Width float64
Align string // L/R/C for PDF
}
func cleanLevels(in []int) []int {
out := make([]int, 0, len(in))
seen := map[int]struct{}{}
for _, v := range in {
if v < 1 || v > 6 {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
sort.Ints(out)
return out
}
func resolvePriceListColumns(req priceListExportRequest) []exportCol {
cols := make([]exportCol, 0, 64)
if req.IncludeMeta {
cols = append(cols,
exportCol{Key: "BrandGroupSec", Title: "MARKA GRUBU", Width: 26, Align: "L"},
exportCol{Key: "Marka", Title: "MARKA", Width: 18, Align: "L"},
exportCol{Key: "BrandCode", Title: "BRAND CODE", Width: 18, Align: "L"},
exportCol{Key: "ProductCode", Title: "URUN KODU", Width: 22, Align: "L"},
exportCol{Key: "StockQty", Title: "STOK ADET", Width: 16, Align: "R"},
exportCol{Key: "StockEntryDate", Title: "STOK GIRIS", Width: 18, Align: "C"},
exportCol{Key: "LastCostingDate", Title: "SON MALIYET", Width: 18, Align: "C"},
exportCol{Key: "LastPricingDate", Title: "SON FIYAT", Width: 18, Align: "C"},
exportCol{Key: "AskiliYan", Title: "ASKILI YAN", Width: 18, Align: "L"},
exportCol{Key: "Kategori", Title: "KATEGORI", Width: 18, Align: "L"},
exportCol{Key: "UrunIlkGrubu", Title: "URUN ILK GRUBU", Width: 20, Align: "L"},
exportCol{Key: "UrunAnaGrubu", Title: "URUN ANA GRUBU", Width: 20, Align: "L"},
exportCol{Key: "UrunAltGrubu", Title: "URUN ALT GRUBU", Width: 20, Align: "L"},
exportCol{Key: "Icerik", Title: "ICERIK", Width: 18, Align: "L"},
exportCol{Key: "Karisim", Title: "KARISIM", Width: 18, Align: "L"},
)
}
if req.IncludeCost {
cols = append(cols, exportCol{Key: "CostPrice", Title: "MALIYET FIYATI", Width: 16, Align: "R"})
}
if req.IncludeBase {
cols = append(cols,
exportCol{Key: "BasePriceUsd", Title: "TABAN USD", Width: 14, Align: "R"},
exportCol{Key: "BasePriceTry", Title: "TABAN TRY", Width: 14, Align: "R"},
)
}
usd := cleanLevels(req.USDLevels)
eur := cleanLevels(req.EURLevels)
tr := cleanLevels(req.TRYLevels)
for _, lv := range usd {
cols = append(cols, exportCol{Key: fmt.Sprintf("USD%d", lv), Title: fmt.Sprintf("USD %d", lv), Width: 12, Align: "R"})
}
for _, lv := range eur {
cols = append(cols, exportCol{Key: fmt.Sprintf("EUR%d", lv), Title: fmt.Sprintf("EUR %d", lv), Width: 12, Align: "R"})
}
for _, lv := range tr {
cols = append(cols, exportCol{Key: fmt.Sprintf("TRY%d", lv), Title: fmt.Sprintf("TRY %d", lv), Width: 12, Align: "R"})
}
return cols
}
func fmtMoneyCell(v float64) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
return ""
}
return fmt.Sprintf("%.2f", v)
}
func getCellValue(row models.ProductPricing, key string) string {
switch key {
case "BrandGroupSec":
return strings.TrimSpace(row.BrandGroupSec)
case "Marka":
return strings.TrimSpace(row.Marka)
case "BrandCode":
return strings.TrimSpace(row.BrandCode)
case "ProductCode":
return strings.TrimSpace(row.ProductCode)
case "StockQty":
return fmtMoneyCell(row.StockQty)
case "StockEntryDate":
return strings.TrimSpace(row.StockEntryDate)
case "LastCostingDate":
return strings.TrimSpace(row.LastCostingDate)
case "LastPricingDate":
return strings.TrimSpace(row.LastPricingDate)
case "AskiliYan":
return strings.TrimSpace(row.AskiliYan)
case "Kategori":
return strings.TrimSpace(row.Kategori)
case "UrunIlkGrubu":
return strings.TrimSpace(row.UrunIlkGrubu)
case "UrunAnaGrubu":
return strings.TrimSpace(row.UrunAnaGrubu)
case "UrunAltGrubu":
return strings.TrimSpace(row.UrunAltGrubu)
case "Icerik":
return strings.TrimSpace(row.Icerik)
case "Karisim":
return strings.TrimSpace(row.Karisim)
case "CostPrice":
return fmtMoneyCell(row.CostPrice)
case "BasePriceUsd":
return fmtMoneyCell(row.BasePriceUsd)
case "BasePriceTry":
return fmtMoneyCell(row.BasePriceTry)
case "USD1":
return fmtMoneyCell(row.USD1)
case "USD2":
return fmtMoneyCell(row.USD2)
case "USD3":
return fmtMoneyCell(row.USD3)
case "USD4":
return fmtMoneyCell(row.USD4)
case "USD5":
return fmtMoneyCell(row.USD5)
case "USD6":
return fmtMoneyCell(row.USD6)
case "EUR1":
return fmtMoneyCell(row.EUR1)
case "EUR2":
return fmtMoneyCell(row.EUR2)
case "EUR3":
return fmtMoneyCell(row.EUR3)
case "EUR4":
return fmtMoneyCell(row.EUR4)
case "EUR5":
return fmtMoneyCell(row.EUR5)
case "EUR6":
return fmtMoneyCell(row.EUR6)
case "TRY1":
return fmtMoneyCell(row.TRY1)
case "TRY2":
return fmtMoneyCell(row.TRY2)
case "TRY3":
return fmtMoneyCell(row.TRY3)
case "TRY4":
return fmtMoneyCell(row.TRY4)
case "TRY5":
return fmtMoneyCell(row.TRY5)
case "TRY6":
return fmtMoneyCell(row.TRY6)
default:
return ""
}
}
func ExportProductPriceListExcelHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
traceID := utils.TraceIDFromRequest(r)
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req priceListExportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if !req.IncludeMeta && !req.IncludeCost && !req.IncludeBase && len(req.USDLevels) == 0 && len(req.EURLevels) == 0 && len(req.TRYLevels) == 0 {
req.IncludeMeta = true
req.IncludeCost = true
req.IncludeBase = true
req.USDLevels = []int{1, 2, 3, 4, 5, 6}
req.EURLevels = []int{1, 2, 3, 4, 5, 6}
req.TRYLevels = []int{1, 2, 3, 4, 5, 6}
}
if req.IncludeMeta == false {
req.IncludeMeta = true
}
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 2*time.Minute)
defer cancel()
filters := queries.ProductPricingFilters{
Search: strings.TrimSpace(req.Search),
ProductCode: req.ProductCode,
BrandGroup: req.BrandGroup,
AskiliYan: req.AskiliYan,
Kategori: req.Kategori,
UrunIlkGrubu: req.UrunIlkGrubu,
UrunAnaGrubu: req.UrunAnaGrubu,
UrunAltGrubu: req.UrunAltGrubu,
Icerik: req.Icerik,
Karisim: req.Karisim,
Marka: req.Marka,
}
rows, err := queries.GetAllProductPricingRows(ctx, 500, filters, "productCode", false)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
if req.InStockOnly {
tmp := make([]models.ProductPricing, 0, len(rows))
for _, it := range rows {
if it.StockQty > 0.0001 {
tmp = append(tmp, it)
}
}
rows = tmp
}
cols := resolvePriceListColumns(req)
f := excelize.NewFile()
defer func() { _ = f.Close() }()
sheet := "Fiyat Listesi"
f.SetSheetName("Sheet1", sheet)
now := time.Now()
title := "BAGGI - GUNCEL FIYAT LISTESI"
dateLine := "Tarih: " + now.Format("02.01.2006")
_ = f.SetCellValue(sheet, "A1", title)
_ = f.SetCellValue(sheet, "A2", dateLine)
_ = f.MergeCell(sheet, "A1", "H1")
_ = f.MergeCell(sheet, "A2", "H2")
// Try to add logo (best-effort).
if logoPath, err := resolvePdfImagePath("Baggi-Tekstil-A.s-Logolu.jpeg"); err == nil {
_ = f.AddPicture(sheet, "I1", logoPath, &excelize.GraphicOptions{
ScaleX: 0.25,
ScaleY: 0.25,
})
}
// Header row
headerRow := 4
for i, c := range cols {
cell, _ := excelize.CoordinatesToCellName(i+1, headerRow)
_ = f.SetCellValue(sheet, cell, c.Title)
colName, _ := excelize.ColumnNumberToName(i + 1)
_ = f.SetColWidth(sheet, colName, colName, c.Width)
}
// Freeze panes at header
_ = f.SetPanes(sheet, &excelize.Panes{
Freeze: true,
Split: false,
XSplit: 0,
YSplit: headerRow,
TopLeftCell: "A5",
ActivePane: "bottomLeft",
})
// Basic styles
hStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "#FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Color: []string{"#957116"}, Pattern: 1},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "#C0C0C0", Style: 1},
{Type: "top", Color: "#C0C0C0", Style: 1},
{Type: "bottom", Color: "#C0C0C0", Style: 1},
{Type: "right", Color: "#C0C0C0", Style: 1},
},
})
lastHeaderCell, _ := excelize.CoordinatesToCellName(len(cols), headerRow)
_ = f.SetCellStyle(sheet, "A4", lastHeaderCell, hStyle)
// Data rows
startRow := headerRow + 1
for ri, row := range rows {
excelRow := startRow + ri
for ci, c := range cols {
cell, _ := excelize.CoordinatesToCellName(ci+1, excelRow)
_ = f.SetCellValue(sheet, cell, getCellValue(row, c.Key))
}
}
// Autofilter
_ = f.AutoFilter(sheet, fmt.Sprintf("A4:%s", lastHeaderCell), []excelize.AutoFilterOptions{})
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
http.Error(w, "excel write error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fmt.Sprintf("baggi_guncel_fiyat_listesi_%s.xlsx", now.Format("20060102"))))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
}
}
func ExportProductPriceListPDFHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
traceID := utils.TraceIDFromRequest(r)
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req priceListExportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if !req.IncludeMeta && !req.IncludeCost && !req.IncludeBase && len(req.USDLevels) == 0 && len(req.EURLevels) == 0 && len(req.TRYLevels) == 0 {
req.IncludeMeta = true
req.IncludeCost = true
req.IncludeBase = true
req.USDLevels = []int{1, 2, 3, 4, 5, 6}
req.EURLevels = []int{1, 2, 3, 4, 5, 6}
req.TRYLevels = []int{1, 2, 3, 4, 5, 6}
}
req.IncludeMeta = true
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 2*time.Minute)
defer cancel()
filters := queries.ProductPricingFilters{
Search: strings.TrimSpace(req.Search),
ProductCode: req.ProductCode,
BrandGroup: req.BrandGroup,
AskiliYan: req.AskiliYan,
Kategori: req.Kategori,
UrunIlkGrubu: req.UrunIlkGrubu,
UrunAnaGrubu: req.UrunAnaGrubu,
UrunAltGrubu: req.UrunAltGrubu,
Icerik: req.Icerik,
Karisim: req.Karisim,
Marka: req.Marka,
}
rows, err := queries.GetAllProductPricingRows(ctx, 500, filters, "productCode", false)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
if req.InStockOnly {
tmp := make([]models.ProductPricing, 0, len(rows))
for _, it := range rows {
if it.StockQty > 0.0001 {
tmp = append(tmp, it)
}
}
rows = tmp
}
cols := resolvePriceListColumns(req)
pdf := gofpdf.New("L", "mm", "A4", "")
pdf.SetMargins(8, 8, 8)
pdf.SetAutoPageBreak(true, 10)
_ = registerDejavuFonts(pdf, "dejavu")
pdf.AddPage()
// Header: logo + title + date
y := 10.0
if logoPath, err := resolvePdfImagePath("Baggi-Tekstil-A.s-Logolu.jpeg"); err == nil {
pdf.ImageOptions(logoPath, 8, y-2, 26, 0, false, gofpdf.ImageOptions{}, 0, "")
}
pdf.SetFont("dejavu", "B", 14)
pdf.SetTextColor(149, 113, 22)
pdf.SetXY(36, y)
pdf.CellFormat(0, 7, "BAGGI - GUNCEL FIYAT LISTESI", "", 0, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetFont("dejavu", "", 9)
pdf.SetXY(36, y+7)
pdf.CellFormat(0, 5, "Tarih: "+time.Now().Format("02.01.2006"), "", 0, "L", false, 0, "")
pdf.SetXY(36, y+12)
pdf.CellFormat(0, 5, "Olusturan: "+strings.TrimSpace(claims.Username), "", 0, "L", false, 0, "")
pdf.Ln(18)
pageW, _ := pdf.GetPageSize()
availW := pageW - 16
sumW := 0.0
for _, c := range cols {
sumW += c.Width
}
scale := 1.0
if sumW > 0 && sumW > availW {
scale = availW / sumW
}
drawRow := func(isHeader bool, values []string) {
h := 6.0
if isHeader {
pdf.SetFillColor(149, 113, 22)
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("dejavu", "B", 7)
} else {
pdf.SetFillColor(255, 255, 255)
pdf.SetTextColor(0, 0, 0)
pdf.SetFont("dejavu", "", 7)
}
for i, c := range cols {
w := c.Width * scale
align := c.Align
if align == "" {
align = "L"
}
txt := ""
if i < len(values) {
txt = values[i]
}
pdf.CellFormat(w, h, txt, "1", 0, align, isHeader, 0, "")
}
pdf.Ln(-1)
}
// Header row
headerVals := make([]string, 0, len(cols))
for _, c := range cols {
headerVals = append(headerVals, c.Title)
}
drawRow(true, headerVals)
for _, row := range rows {
vals := make([]string, 0, len(cols))
for _, c := range cols {
vals = append(vals, getCellValue(row, c.Key))
}
drawRow(false, vals)
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
http.Error(w, "pdf render error", http.StatusInternalServerError)
return
}
now := time.Now()
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fmt.Sprintf("baggi_guncel_fiyat_listesi_%s.pdf", now.Format("20060102"))))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
}
}

View File

@@ -0,0 +1,1195 @@
package routes
import (
"bssapp-backend/auth"
"bssapp-backend/db"
"bssapp-backend/internal/mailer"
"bssapp-backend/queries"
"bssapp-backend/utils"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"log/slog"
"math"
"net/http"
"strconv"
"strings"
"time"
)
type productPricingSaveItem struct {
ProductCode string `json:"product_code"`
BasePriceUsd float64 `json:"base_price_usd"`
BasePriceTry float64 `json:"base_price_try"`
USD1 float64 `json:"usd1"`
USD2 float64 `json:"usd2"`
USD3 float64 `json:"usd3"`
USD4 float64 `json:"usd4"`
USD5 float64 `json:"usd5"`
USD6 float64 `json:"usd6"`
EUR1 float64 `json:"eur1"`
EUR2 float64 `json:"eur2"`
EUR3 float64 `json:"eur3"`
EUR4 float64 `json:"eur4"`
EUR5 float64 `json:"eur5"`
EUR6 float64 `json:"eur6"`
TRY1 float64 `json:"try1"`
TRY2 float64 `json:"try2"`
TRY3 float64 `json:"try3"`
TRY4 float64 `json:"try4"`
TRY5 float64 `json:"try5"`
TRY6 float64 `json:"try6"`
}
type productPricingSavePayload struct {
Items []productPricingSaveItem `json:"items"`
}
func resolveOrCreatePriceListHeaderID(ctx context.Context, tx *sql.Tx, priceGroup string, currency string, username string, logger *slog.Logger) (string, error) {
priceGroup = strings.TrimSpace(priceGroup)
currency = strings.ToUpper(strings.TrimSpace(currency))
if priceGroup == "" {
return "", fmt.Errorf("empty price group")
}
if currency != "USD" && currency != "EUR" && currency != "TRY" {
return "", fmt.Errorf("invalid currency")
}
// Try existing header for group+currency.
var headerID string
_ = tx.QueryRowContext(ctx, `
SELECT TOP (1) CONVERT(NVARCHAR(36), PriceListHeaderID)
FROM dbo.trPriceListHeader WITH (UPDLOCK, HOLDLOCK)
WHERE CompanyCode = 1
AND LTRIM(RTRIM(PriceGroupCode)) = @pg
AND LTRIM(RTRIM(DocCurrencyCode)) = @cur
ORDER BY ValidDate DESC, ValidTime DESC, LastUpdatedDate DESC;
`, sql.Named("pg", priceGroup), sql.Named("cur", currency)).Scan(&headerID)
headerID = strings.TrimSpace(headerID)
if headerID != "" {
logger.Info("save:mssql:header:resolved",
"price_group", priceGroup,
"currency", currency,
"header_id", headerID,
)
return headerID, nil
}
// Create header (PriceListNumber pattern: "1-<seq>").
// Note: PriceListNumber is unique (constraint seen as UQ_trPriceListHeader_1), so compute next and retry on collisions.
isTaxIncluded := 0
if strings.HasPrefix(strings.ToUpper(priceGroup), "B2C-") {
isTaxIncluded = 1
}
var priceListNumber string
var err error
for attempt := 1; attempt <= 5; attempt++ {
var nextSeq int64
if err2 := tx.QueryRowContext(ctx, `
SELECT ISNULL(MAX(CASE WHEN v.n >= 10000 THEN v.n END), 9999) + 1
FROM dbo.trPriceListHeader h WITH (UPDLOCK, HOLDLOCK)
CROSS APPLY (VALUES (
SUBSTRING(LTRIM(RTRIM(h.PriceListNumber)),
CHARINDEX('-', LTRIM(RTRIM(h.PriceListNumber))) + 1,
50)
)) s(sfx)
CROSS APPLY (VALUES (
CASE
WHEN s.sfx NOT LIKE '%[^0-9]%' THEN CAST(s.sfx AS BIGINT)
ELSE NULL
END
)) v(n)
WHERE LTRIM(RTRIM(h.PriceListNumber)) LIKE '1-%'
AND CHARINDEX('-', LTRIM(RTRIM(h.PriceListNumber))) > 0;
`).Scan(&nextSeq); err2 != nil {
// If we cannot compute the next sequence (SQL dialect/version), log and fall back to the starting point.
logger.Error("save:mssql:header:nextseq:error",
"price_group", priceGroup,
"currency", currency,
"attempt", attempt,
"err", err2,
)
nextSeq = 10000
}
if nextSeq <= 0 {
nextSeq = 10000
}
if nextSeq < 10000 {
nextSeq = 10000
}
priceListNumber = fmt.Sprintf("1-%d", nextSeq)
_, err = tx.ExecContext(ctx, `
DECLARE @HeaderID UNIQUEIDENTIFIER = NEWID();
INSERT INTO dbo.trPriceListHeader (
PriceListHeaderID,
PriceListNumber,
PriceListDate,
PriceListTime,
PriceListTypeCode,
CompanyCode,
PriceGroupCode,
ValidDate,
ValidTime,
DocCurrencyCode,
Description,
IsTaxIncluded,
IsCompleted,
IsPrinted,
IsLocked,
IsConfirmed,
ConfirmedUserName,
ConfirmedDate,
ApplicationCode,
ApplicationID,
CreatedUserName,
CreatedDate,
LastUpdatedUserName,
LastUpdatedDate
)
VALUES (
@HeaderID,
@PriceListNumber,
CONVERT(date, GETDATE()),
'00:00:00',
'',
1,
@PriceGroupCode,
CONVERT(date, GETDATE()),
'00:00:00',
@Currency,
@Description,
@IsTaxIncluded,
1,
0,
0,
1,
@UserName,
GETDATE(),
'Price',
CONVERT(NVARCHAR(36), @HeaderID),
@UserName,
GETDATE(),
@UserName,
GETDATE()
);
`, sql.Named("PriceListNumber", priceListNumber),
sql.Named("PriceGroupCode", priceGroup),
sql.Named("Currency", currency),
sql.Named("Description", priceGroup),
sql.Named("IsTaxIncluded", isTaxIncluded),
sql.Named("UserName", username),
)
if err == nil {
break
}
low := strings.ToLower(err.Error())
if strings.Contains(low, "uq_trpricelistheader_1") || strings.Contains(low, "duplicate key") {
logger.Warn("save:mssql:header:create:collision",
"price_group", priceGroup,
"currency", currency,
"price_list_number", priceListNumber,
"attempt", attempt,
"err", err,
)
time.Sleep(time.Duration(20*attempt) * time.Millisecond)
continue
}
return "", fmt.Errorf("create trPriceListHeader failed for PriceGroupCode=%s currency=%s: %w", priceGroup, currency, err)
}
if err != nil {
return "", fmt.Errorf("create trPriceListHeader failed for PriceGroupCode=%s currency=%s: %w", priceGroup, currency, err)
}
// Re-read header id.
err = tx.QueryRowContext(ctx, `
SELECT TOP (1) CONVERT(NVARCHAR(36), PriceListHeaderID)
FROM dbo.trPriceListHeader WITH (NOLOCK)
WHERE CompanyCode = 1
AND LTRIM(RTRIM(PriceGroupCode)) = @pg
AND LTRIM(RTRIM(DocCurrencyCode)) = @cur
ORDER BY CreatedDate DESC, LastUpdatedDate DESC;
`, sql.Named("pg", priceGroup), sql.Named("cur", currency)).Scan(&headerID)
if err != nil {
return "", fmt.Errorf("create header ok but cannot re-read header id: %w", err)
}
headerID = strings.TrimSpace(headerID)
if headerID == "" {
return "", fmt.Errorf("create header ok but header id is empty")
}
logger.Info("save:mssql:header:created",
"price_group", priceGroup,
"currency", currency,
"header_id", headerID,
"price_list_number", priceListNumber,
)
return headerID, nil
}
func PostProductPricingSaveHandler(pg *sql.DB, ml *mailer.GraphMailer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
traceID := utils.TraceIDFromRequest(r)
w.Header().Set("X-Trace-ID", traceID)
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var payload productPricingSavePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if len(payload.Items) == 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "saved": 0})
return
}
// Basic validation early.
for _, it := range payload.Items {
if strings.TrimSpace(it.ProductCode) == "" {
http.Error(w, "product_code is required", http.StatusBadRequest)
return
}
if it.BasePriceUsd < 0 || it.BasePriceTry < 0 {
http.Error(w, "base prices must be >= 0", http.StatusBadRequest)
return
}
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
ctx = utils.ContextWithTraceID(ctx, traceID)
logger := utils.SlogFromContext(ctx).With("handler", "product-pricing.save", "trace_id", traceID, "user", claims.Username, "user_id", claims.ID)
mssql := db.GetDB()
if mssql == nil {
http.Error(w, "mssql not connected", http.StatusInternalServerError)
return
}
pgTx, err := pg.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "pg transaction start error", http.StatusInternalServerError)
return
}
defer pgTx.Rollback()
msTx, err := mssql.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "mssql transaction start error", http.StatusInternalServerError)
return
}
defer msTx.Rollback()
// Serialize writes to pricing tables in PG to avoid contention with other pricing jobs.
if _, err := pgTx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(2001, 1)`); err != nil {
http.Error(w, "pg advisory lock error", http.StatusInternalServerError)
return
}
savedPG := 0
savedMSSQL := 0
missingPG := 0
missingMSSQL := 0
// Load mapping tables once.
pgMap := map[string]map[int]int{} // currency -> level -> sdprcgrp_id
nebimMap := map[string]map[int]string{} // currency -> level -> price_group_code
{
rows, err := pgTx.QueryContext(ctx, `
SELECT currency, level_no, COALESCE(sdprcgrp_id, 0)
FROM mk_price_target_map_pg
WHERE is_active = TRUE
`)
if err == nil {
for rows.Next() {
var cur string
var level int
var grp int
if err := rows.Scan(&cur, &level, &grp); err != nil {
_ = rows.Close()
http.Error(w, "pg map scan error", http.StatusInternalServerError)
return
}
cur = strings.ToUpper(strings.TrimSpace(cur))
if cur == "" || level <= 0 || level > 6 || grp <= 0 {
continue
}
// In this setup sdprcgrp_id is expected to be 1..6. Guard against stale/invalid mappings.
if grp < 1 || grp > 6 {
continue
}
if pgMap[cur] == nil {
pgMap[cur] = map[int]int{}
}
pgMap[cur][level] = grp
}
_ = rows.Close()
}
}
{
rows, err := pgTx.QueryContext(ctx, `
SELECT currency, level_no, COALESCE(NULLIF(BTRIM(price_group_code), ''), '')
FROM mk_price_target_map_nebim
WHERE is_active = TRUE
`)
if err == nil {
for rows.Next() {
var cur string
var level int
var code string
if err := rows.Scan(&cur, &level, &code); err != nil {
_ = rows.Close()
http.Error(w, "nebim map scan error", http.StatusInternalServerError)
return
}
cur = strings.ToUpper(strings.TrimSpace(cur))
code = strings.TrimSpace(code)
if cur == "" || level <= 0 || level > 6 || code == "" {
continue
}
if nebimMap[cur] == nil {
nebimMap[cur] = map[int]string{}
}
nebimMap[cur][level] = code
}
_ = rows.Close()
}
}
changed := make(map[string]struct{}, len(payload.Items))
// In-request cache to avoid repeating expensive dim resolution work.
// Key: "<column>|<TOKEN>" where token is uppercased/trimmed.
dimTokenLocalCache := make(map[string]int64, 256)
type dimCombo struct {
Dim1 int64
Dim3 sql.NullInt64
}
type sdprcWriteRow struct {
Currency string `json:"currency"`
SdprcGrpID int `json:"sdprcgrp_id"`
Dim1 int64 `json:"dim1"`
Dim3 *int64 `json:"dim3"`
Price float64 `json:"price"`
}
loadDimCombosFromCache := func(productCode string) ([]dimCombo, error) {
productCode = strings.TrimSpace(productCode)
if productCode == "" {
return nil, nil
}
rows, err := pgTx.QueryContext(ctx, `
SELECT dim1, dim3
FROM mk_mmitem_dim_combo
WHERE product_code = $1
ORDER BY dim1, dim3_key
`, productCode)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]dimCombo, 0, 32)
for rows.Next() {
var d1 int64
var d3 sql.NullInt64
if err := rows.Scan(&d1, &d3); err != nil {
return nil, err
}
if d1 <= 0 {
continue
}
out = append(out, dimCombo{Dim1: d1, Dim3: d3})
}
return out, rows.Err()
}
parseDimID := func(s string) (int64, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
// tolerate leading zeros like "001"
s2 := strings.TrimLeft(s, "0")
if s2 == "" {
s2 = "0"
}
n, err := strconv.ParseInt(s2, 10, 64)
if err != nil || n <= 0 {
return 0, false
}
return n, true
}
type queryRower interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
resolveDimvalFromToken := func(q queryRower, column, token string) (int64, bool) {
token = strings.ToUpper(normalizeDimParam(token))
if token == "" {
return 0, false
}
cacheKey := column + "|" + token
if v, ok := dimTokenLocalCache[cacheKey]; ok {
return v, v > 0
}
// Fast path: persistent token->id mapping table.
{
var id int64
if err := pgTx.QueryRowContext(ctx, `
SELECT dim_id
FROM mk_dim_token_map
WHERE dim_column = $1 AND token = $2
`, column, token).Scan(&id); err == nil && id > 0 {
dimTokenLocalCache[cacheKey] = id
return id, true
}
}
patterns := buildNameLikePatterns(token)
if len(patterns) == 0 {
dimTokenLocalCache[cacheKey] = 0
return 0, false
}
query := fmt.Sprintf(`
SELECT x.dimv
FROM (
SELECT COALESCE(%s::text, '') AS dimv, COUNT(*) AS cnt
FROM dfblob
WHERE src_table='mmitem'
AND typ='img'
AND COALESCE(%s::text, '') <> ''
AND (
UPPER(COALESCE(file_name,'')) LIKE $1 OR
UPPER(COALESCE(file_name,'')) LIKE $2 OR
UPPER(COALESCE(file_name,'')) LIKE $3 OR
UPPER(COALESCE(file_name,'')) LIKE $4 OR
UPPER(COALESCE(file_name,'')) LIKE $5 OR
UPPER(COALESCE(file_name,'')) LIKE $6
)
GROUP BY COALESCE(%s::text, '')
) x
ORDER BY x.cnt DESC, x.dimv
LIMIT 1
`, column, column, column)
var v string
if err := q.QueryRowContext(ctx,
query,
patterns[0],
patterns[1],
patterns[2],
patterns[3],
patterns[4],
patterns[5],
).Scan(&v); err != nil {
dimTokenLocalCache[cacheKey] = 0
return 0, false
}
v = normalizeDimParam(v)
if v == "" {
dimTokenLocalCache[cacheKey] = 0
return 0, false
}
id, ok := parseDimID(v)
if !ok {
dimTokenLocalCache[cacheKey] = 0
return 0, false
}
// Persist for future requests (best-effort).
_, _ = pgTx.ExecContext(ctx, `
INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at)
VALUES ($1,$2,$3,now())
ON CONFLICT (dim_column, token)
DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
`, column, token, id)
dimTokenLocalCache[cacheKey] = id
return id, true
}
loadDimsFromMssqlStock := func(productCode string) ([]dimCombo, error) {
started := time.Now()
if db.MssqlDB == nil {
return nil, fmt.Errorf("mssql not ready")
}
rows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductVariantDimsForPricing, productCode)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]dimCombo, 0, 32)
seen := make(map[string]struct{}, 64)
readRows := 0
resolvedDim1 := 0
resolvedDim3 := 0
for rows.Next() {
readRows++
var colorCode, dim1Code, dim3Code string
if err := rows.Scan(&colorCode, &dim1Code, &dim3Code); err != nil {
return nil, err
}
// Resolve to PG dim ids (e-commerce expects integer ids, e.g. dim1=82).
d1 := int64(0)
if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim1Code); ok {
d1 = id
resolvedDim1++
} else if id, ok := resolveDimvalFromToken(pgTx, "dimval1", colorCode); ok {
d1 = id
resolvedDim1++
}
if d1 <= 0 {
continue
}
var d3 sql.NullInt64
if id, ok := resolveDimvalFromToken(pgTx, "dimval3", dim3Code); ok {
d3 = sql.NullInt64{Int64: id, Valid: true}
resolvedDim3++
}
key := fmt.Sprintf("%d|%d", d1, func() int64 {
if d3.Valid {
return d3.Int64
}
return 0
}())
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, dimCombo{Dim1: d1, Dim3: d3})
}
if err := rows.Err(); err != nil {
return nil, err
}
logger.Info("save:pg:dims:mssql:resolved",
"product_code", strings.TrimSpace(productCode),
"rows_read", readRows,
"dims", len(out),
"resolved_dim1", resolvedDim1,
"resolved_dim3", resolvedDim3,
"duration_ms", time.Since(started).Milliseconds(),
)
return out, nil
}
upsertDimCombosCache := func(productCode string, dims []dimCombo) error {
productCode = strings.TrimSpace(productCode)
if productCode == "" || len(dims) == 0 {
return nil
}
for _, d := range dims {
_, err := pgTx.ExecContext(ctx, `
INSERT INTO mk_mmitem_dim_combo (product_code, dim1, dim3, updated_at)
VALUES ($1,$2,$3,now())
ON CONFLICT (product_code, dim1, dim3_key)
DO UPDATE SET updated_at = EXCLUDED.updated_at
`, productCode, d.Dim1, func() any {
if d.Dim3.Valid {
return d.Dim3.Int64
}
return nil
}())
if err != nil {
return err
}
}
return nil
}
bulkAppendOnlyInsertSdprc := func(mmItemID int64, productCode string, rows []sdprcWriteRow) (int, error) {
if mmItemID <= 0 {
return 0, fmt.Errorf("invalid mmitem_id")
}
if len(rows) == 0 {
return 0, nil
}
raw, err := json.Marshal(rows)
if err != nil {
return 0, err
}
q := `
WITH input AS (
SELECT *
FROM jsonb_to_recordset($1::jsonb) AS x(currency text, sdprcgrp_id int, dim1 bigint, dim3 bigint, price float8)
),
norm AS (
SELECT
UPPER(NULLIF(BTRIM(currency), '')) AS currency,
COALESCE(sdprcgrp_id, 0) AS sdprcgrp_id,
COALESCE(dim1, 0) AS dim1,
dim3 AS dim3,
COALESCE(price, 0) AS price
FROM input
),
filtered AS (
SELECT *
FROM norm
WHERE currency IN ('USD','EUR','TRY')
AND sdprcgrp_id BETWEEN 1 AND 6
AND dim1 > 0
AND price > 0
),
latest AS (
SELECT DISTINCT ON (s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0))
s.sdprcgrp_id,
s.crn,
s.dim1,
s.dim3,
s.prc
FROM sdprc s
WHERE s.mmitem_id = $2
AND (s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0)) IN (
SELECT sdprcgrp_id, currency, dim1, COALESCE(dim3, 0) FROM filtered
)
ORDER BY s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0), s.zlins_dttm DESC, s.id DESC
),
to_insert AS (
SELECT
$2::bigint AS mmitem_id,
f.sdprcgrp_id,
f.currency AS crn,
f.dim1,
f.dim3,
f.price AS prc
FROM filtered f
LEFT JOIN latest l
ON l.sdprcgrp_id = f.sdprcgrp_id
AND l.crn = f.currency
AND l.dim1 = f.dim1
AND ((l.dim3 IS NULL AND f.dim3 IS NULL) OR l.dim3 = f.dim3)
WHERE l.prc IS NULL OR l.prc IS DISTINCT FROM f.price
),
ins AS (
INSERT INTO sdprc (mmitem_id, sdprcgrp_id, crn, dim1, dim3, prc, zlins_dttm)
SELECT mmitem_id, sdprcgrp_id, crn, dim1, dim3, prc, now()
FROM to_insert
RETURNING 1
)
SELECT COUNT(*)::int FROM ins;
`
var inserted int
if err := pgTx.QueryRowContext(ctx, q, raw, mmItemID).Scan(&inserted); err != nil {
return 0, err
}
if inserted > 0 {
savedPG += inserted
changed[productCode] = struct{}{}
}
return inserted, nil
}
// MSSQL memoization: reduce chatter for large batches.
// header id cache key: "<CUR>|<PRICEGROUP>"
msHeaderIDCache := make(map[string]string, 64)
// next sort cache key: "<HEADERID>"
msHeaderNextSort := make(map[string]int64, 64)
type msLatestKey struct {
Cur string
PriceGroup string
}
loadLatestPricesForProduct := func(productCode string, pairs []msLatestKey) (map[string]float64, map[string]bool) {
out := make(map[string]float64, len(pairs))
ok := make(map[string]bool, len(pairs))
productCode = strings.TrimSpace(productCode)
if productCode == "" || len(pairs) == 0 {
return out, ok
}
conds := make([]string, 0, len(pairs))
args := []any{sql.Named("ItemCode", productCode)}
for i, p := range pairs {
pg := strings.TrimSpace(p.PriceGroup)
cur := strings.ToUpper(strings.TrimSpace(p.Cur))
if pg == "" || (cur != "USD" && cur != "EUR" && cur != "TRY") {
continue
}
args = append(args,
sql.Named(fmt.Sprintf("pg%d", i), pg),
sql.Named(fmt.Sprintf("cur%d", i), cur),
)
conds = append(conds,
fmt.Sprintf("(LTRIM(RTRIM(PriceGroupCode)) = @pg%d AND LTRIM(RTRIM(DocCurrencyCode)) = @cur%d)", i, i),
)
}
if len(conds) == 0 {
return out, ok
}
q := fmt.Sprintf(`
SELECT PriceGroupCode, DocCurrencyCode, Price
FROM (
SELECT
LTRIM(RTRIM(PriceGroupCode)) AS PriceGroupCode,
LTRIM(RTRIM(DocCurrencyCode)) AS DocCurrencyCode,
CAST(Price AS FLOAT) AS Price,
ROW_NUMBER() OVER (
PARTITION BY LTRIM(RTRIM(PriceGroupCode)), LTRIM(RTRIM(DocCurrencyCode))
ORDER BY ValidDate DESC, ValidTime DESC, LastUpdatedDate DESC
) AS rn
FROM dbo.trPriceListLine WITH(NOLOCK)
WHERE ItemTypeCode = 1
AND LTRIM(RTRIM(ItemCode)) = @ItemCode
AND ISNULL(IsDisabled, 0) = 0
AND (%s)
) x
WHERE rn = 1;
`, strings.Join(conds, " OR "))
rows, err := msTx.QueryContext(ctx, q, args...)
if err != nil {
logger.Warn("save:mssql:latest:prefetch:error", "product_code", productCode, "err", err)
return out, ok
}
defer rows.Close()
for rows.Next() {
var pg, cur string
var price float64
if err := rows.Scan(&pg, &cur, &price); err != nil {
logger.Warn("save:mssql:latest:prefetch:scan:error", "product_code", productCode, "err", err)
return out, ok
}
pg = strings.TrimSpace(pg)
cur = strings.ToUpper(strings.TrimSpace(cur))
k := cur + "|" + pg
out[k] = price
ok[k] = true
}
return out, ok
}
// Helper: append-only Nebim price list line (insert new row when price changes).
// Resolve PriceListHeaderID from trPriceListHeader (source of truth).
// If header does not exist for the given PriceGroupCode+Currency, create it, then insert lines under that header.
upsertPriceListLine := func(productCode string, currency string, priceGroup string, price float64, latest map[string]float64, latestOK map[string]bool) (bool, error) {
currency = strings.ToUpper(strings.TrimSpace(currency))
priceGroup = strings.TrimSpace(priceGroup)
if price <= 0 {
return false, nil
}
if currency != "USD" && currency != "EUR" && currency != "TRY" {
return false, fmt.Errorf("invalid currency")
}
if priceGroup == "" {
return false, fmt.Errorf("empty price group")
}
// Resolve or create header id for that group/currency (memoized).
headerKey := currency + "|" + priceGroup
headerID := strings.TrimSpace(msHeaderIDCache[headerKey])
if headerID == "" {
var err error
headerID, err = resolveOrCreatePriceListHeaderID(ctx, msTx, priceGroup, currency, claims.Username, logger)
if err != nil {
return false, err
}
msHeaderIDCache[headerKey] = headerID
}
// If latest line already has the same price, no-op (prefer prefetch map).
if latest != nil && latestOK != nil && latestOK[headerKey] {
if curLatest, ok := latest[headerKey]; ok && math.Abs(curLatest-price) < 1e-9 {
return false, nil
}
} else {
// Fallback: query latest for this key if not prefetched.
var latestPrice sql.NullFloat64
_ = msTx.QueryRowContext(ctx, `
SELECT TOP (1) CAST(Price AS FLOAT)
FROM dbo.trPriceListLine WITH(NOLOCK)
WHERE ItemTypeCode = 1
AND LTRIM(RTRIM(ItemCode)) = @p1
AND LTRIM(RTRIM(DocCurrencyCode)) = @p2
AND LTRIM(RTRIM(PriceGroupCode)) = @p3
AND ISNULL(IsDisabled, 0) = 0
ORDER BY ValidDate DESC, ValidTime DESC, LastUpdatedDate DESC;
`, sql.Named("p1", productCode), sql.Named("p2", currency), sql.Named("p3", priceGroup)).Scan(&latestPrice)
if latestPrice.Valid && math.Abs(latestPrice.Float64-price) < 1e-9 {
return false, nil
}
}
// SortOrder: append inside header.
nextSort := msHeaderNextSort[headerID]
if nextSort <= 0 {
_ = msTx.QueryRowContext(ctx, `
SELECT ISNULL(MAX(SortOrder), 0) + 1
FROM dbo.trPriceListLine WITH(NOLOCK)
WHERE PriceListHeaderID = CONVERT(UNIQUEIDENTIFIER, @p1);
`, sql.Named("p1", headerID)).Scan(&nextSort)
if nextSort <= 0 {
nextSort = 1
}
}
msHeaderNextSort[headerID] = nextSort + 1
// Insert minimal line.
_, err := msTx.ExecContext(ctx, `
INSERT INTO dbo.trPriceListLine (
PriceListLineID,
SortOrder,
ItemTypeCode,
ItemCode,
ColorCode,
ItemDim1Code,
ItemDim2Code,
ItemDim3Code,
UnitOfMeasureCode,
PaymentPlanCode,
LineDescription,
DocCurrencyCode,
Price,
IsDisabled,
DisableDate,
CompanyCode,
PriceGroupCode,
ValidDate,
ValidTime,
PriceListHeaderID,
CreatedUserName,
CreatedDate,
LastUpdatedUserName,
LastUpdatedDate
)
VALUES (
NEWID(),
@SortOrder,
1,
@ItemCode,
'',
'',
'',
'',
'AD',
'',
'',
@Currency,
@Price,
0,
'1900-01-01',
1,
@PriceGroupCode,
CONVERT(date, GETDATE()),
'00:00:00',
CONVERT(uniqueidentifier, @HeaderID),
@UserName,
GETDATE(),
@UserName,
GETDATE()
);
`, sql.Named("SortOrder", nextSort),
sql.Named("ItemCode", productCode),
sql.Named("Currency", currency),
sql.Named("Price", price),
sql.Named("PriceGroupCode", priceGroup),
sql.Named("HeaderID", headerID),
sql.Named("UserName", claims.Username),
)
if err != nil {
return false, err
}
return true, nil
}
for _, it := range payload.Items {
code := strings.TrimSpace(it.ProductCode)
if code == "" {
continue
}
var latestMap map[string]float64
var latestOK map[string]bool
var mmItemID int64
if err := pgTx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmItemID); err != nil {
// If missing in PG, we can still save MSSQL tiers; PG write will be skipped.
mmItemID = 0
}
dims := []dimCombo{}
// Prefer cached dim combos (fast). If not present, load from Nebim stock query (used by product-stock-query UI).
if mmItemID > 0 {
cacheStarted := time.Now()
cached, cacheErr := loadDimCombosFromCache(code)
if cacheErr == nil && len(cached) > 0 {
dims = cached
logger.Info("save:pg:dims:cache:hit",
"product_code", code,
"dims", len(dims),
"duration_ms", time.Since(cacheStarted).Milliseconds(),
)
} else if cacheErr != nil {
logger.Error("save:pg:dims:cache-load:error", "product_code", code, "err", cacheErr)
} else {
logger.Info("save:pg:dims:cache:miss",
"product_code", code,
"duration_ms", time.Since(cacheStarted).Milliseconds(),
)
}
if len(dims) == 0 {
d, err := loadDimsFromMssqlStock(code)
if err != nil {
logger.Error("save:pg:dims:mssql:error", "product_code", code, "err", err)
} else {
dims = d
if err := upsertDimCombosCache(code, dims); err != nil {
logger.Error("save:pg:dims:cache:error", "product_code", code, "err", err)
}
}
}
}
// Tier prices in PG sdprc + Nebim price list lines (mapped).
type tier struct {
Cur string
Level int
Price float64
}
tiers := []tier{
{"USD", 1, it.USD1}, {"USD", 2, it.USD2}, {"USD", 3, it.USD3}, {"USD", 4, it.USD4}, {"USD", 5, it.USD5}, {"USD", 6, it.USD6},
{"EUR", 1, it.EUR1}, {"EUR", 2, it.EUR2}, {"EUR", 3, it.EUR3}, {"EUR", 4, it.EUR4}, {"EUR", 5, it.EUR5}, {"EUR", 6, it.EUR6},
{"TRY", 1, it.TRY1}, {"TRY", 2, it.TRY2}, {"TRY", 3, it.TRY3}, {"TRY", 4, it.TRY4}, {"TRY", 5, it.TRY5}, {"TRY", 6, it.TRY6},
}
// Prefetch MSSQL latest prices for all relevant pairs for this product.
// This turns N tier "TOP 1" lookups into a single query per product.
{
msPairs := make([]msLatestKey, 0, 24)
seen := make(map[string]struct{}, 32)
addPair := func(cur, pg string) {
cur = strings.ToUpper(strings.TrimSpace(cur))
pg = strings.TrimSpace(pg)
if pg == "" {
return
}
k := cur + "|" + pg
if _, ok := seen[k]; ok {
return
}
seen[k] = struct{}{}
msPairs = append(msPairs, msLatestKey{Cur: cur, PriceGroup: pg})
}
if it.BasePriceUsd > 0 {
addPair("USD", "TM-USD")
}
if it.BasePriceTry > 0 {
addPair("TRY", "TM-TRY")
}
for _, t := range tiers {
if t.Price <= 0 {
continue
}
nebimGrp := ""
if nebimMap[t.Cur] != nil {
nebimGrp = nebimMap[t.Cur][t.Level]
}
if nebimGrp == "" {
continue
}
addPair(t.Cur, nebimGrp)
}
latestMap, latestOK = loadLatestPricesForProduct(code, msPairs)
}
// Base prices in Nebim price lists.
{
ch, err := upsertPriceListLine(code, "USD", "TM-USD", it.BasePriceUsd, latestMap, latestOK)
if err != nil {
logger.Error("save:mssql:base-usd:error", "product_code", code, "err", err)
http.Error(w, "mssql base price save error: "+err.Error(), http.StatusInternalServerError)
return
}
if ch {
changed[code] = struct{}{}
savedMSSQL++
}
ch, err = upsertPriceListLine(code, "TRY", "TM-TRY", it.BasePriceTry, latestMap, latestOK)
if err != nil {
logger.Error("save:mssql:base-try:error", "product_code", code, "err", err)
http.Error(w, "mssql base price save error: "+err.Error(), http.StatusInternalServerError)
return
}
if ch {
changed[code] = struct{}{}
savedMSSQL++
}
}
// PG write: bulk append-only insert across dims (fast).
if mmItemID > 0 && len(dims) > 0 {
writeRows := make([]sdprcWriteRow, 0, len(dims)*len(tiers))
for _, t := range tiers {
if t.Price <= 0 {
continue
}
pgGrp := 0
if pgMap[t.Cur] != nil {
pgGrp = pgMap[t.Cur][t.Level]
}
if pgGrp <= 0 {
pgGrp = t.Level
}
for _, dc := range dims {
var d3 *int64
if dc.Dim3.Valid {
v := dc.Dim3.Int64
d3 = &v
}
writeRows = append(writeRows, sdprcWriteRow{
Currency: t.Cur,
SdprcGrpID: pgGrp,
Dim1: dc.Dim1,
Dim3: d3,
Price: t.Price,
})
}
}
if len(writeRows) > 0 {
startPG := time.Now()
inserted, err := bulkAppendOnlyInsertSdprc(mmItemID, code, writeRows)
if err != nil {
logger.Error("save:pg:sdprc:bulk:error", "product_code", code, "dims", len(dims), "rows", len(writeRows), "err", err)
http.Error(w, "postgres tier save error: "+err.Error(), http.StatusInternalServerError)
return
}
logger.Info("save:pg:sdprc:bulk:ok", "product_code", code, "dims", len(dims), "rows", len(writeRows), "inserted", inserted, "duration_ms", time.Since(startPG).Milliseconds())
}
} else {
for _, t := range tiers {
if t.Price > 0 {
missingPG++
logger.Warn("save:pg:sdprc:skip:no-dims", "product_code", code, "currency", t.Cur, "level", t.Level)
}
}
}
// MSSQL tier writes (mapped).
for _, t := range tiers {
nebimGrp := ""
if nebimMap[t.Cur] != nil {
nebimGrp = nebimMap[t.Cur][t.Level]
}
if nebimGrp == "" {
if t.Price > 0 {
missingMSSQL++
}
continue
}
msChanged, err := upsertPriceListLine(code, t.Cur, nebimGrp, t.Price, latestMap, latestOK)
if err != nil {
logger.Error("save:mssql:tier:error", "product_code", code, "currency", t.Cur, "level", t.Level, "price_group", nebimGrp, "err", err)
http.Error(w, "mssql tier save error: "+err.Error(), http.StatusInternalServerError)
return
}
if msChanged {
changed[code] = struct{}{}
savedMSSQL++
}
}
}
// Delta queue: only products with an explicit price change record should be processed by delta jobs.
{
codes := make([]string, 0, len(changed))
for c := range changed {
codes = append(codes, c)
}
if _, err := queries.EnqueuePriceRecalc(ctx, pgTx, codes, "manual_price_save"); err != nil {
logger.Error("save:enqueue:error", "err", err)
http.Error(w, "price recalc enqueue error: "+err.Error(), http.StatusInternalServerError)
return
}
}
if err := msTx.Commit(); err != nil {
logger.Error("save:mssql:commit:error", "err", err)
http.Error(w, "mssql commit error", http.StatusInternalServerError)
return
}
if err := pgTx.Commit(); err != nil {
logger.Error("save:pg:commit:error", "err", err)
http.Error(w, "postgres commit error", http.StatusInternalServerError)
return
}
// Post-commit pricing mail: only for actually changed products.
if ml != nil && len(changed) > 0 {
changedCodes := make([]string, 0, len(changed))
for c := range changed {
changedCodes = append(changedCodes, c)
}
actor := claims.Username
go sendPricingChangeMails(context.Background(), ml, changedCodes, actor)
}
// Immediate FX delta publish kick (best-effort): run right away for changed products.
// Queue entries are still created for reliability; on success we mark them done to avoid a second pass.
if len(changed) > 0 {
changedCodes := make([]string, 0, len(changed))
for c := range changed {
changedCodes = append(changedCodes, c)
}
go func(codes []string) {
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel2()
written, fxDateYmd, err := queries.PublishDerivedPricesFromAnchor(ctx2, pg, codes, "", false)
if err != nil {
log.Printf("[PricingFxImmediate] publish_error codes=%d err=%v", len(codes), err)
return
}
tx2, err := pg.BeginTx(ctx2, nil)
if err == nil {
_, _ = queries.MarkPriceRecalcQueueDoneByProductCodes(ctx2, tx2, codes)
_ = tx2.Commit()
}
log.Printf("[PricingFxImmediate] ok codes=%d sdprc_written=%d fx_date_ymd=%d", len(codes), written, fxDateYmd)
}(changedCodes)
}
logger.Info("save:done",
"items", len(payload.Items),
"saved_pg", savedPG,
"saved_mssql", savedMSSQL,
"missing_pg", missingPG,
"missing_mssql", missingMSSQL,
"duration_ms", time.Since(started).Milliseconds(),
)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"saved_pg": savedPG,
"saved_mssql": savedMSSQL,
"missing_pg": missingPG,
"missing_mssql": missingMSSQL,
})
}
}