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

@@ -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,
})
}
}