Merge remote-tracking branch 'origin/master'
This commit is contained in:
97
svc/routes/brand_group_currency.go
Normal file
97
svc/routes/brand_group_currency.go
Normal 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})
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
107
svc/routes/product_pricing_calc.go
Normal file
107
svc/routes/product_pricing_calc.go
Normal 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)
|
||||
}
|
||||
}
|
||||
265
svc/routes/product_pricing_change_mail.go
Normal file
265
svc/routes/product_pricing_change_mail.go
Normal 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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
505
svc/routes/product_pricing_history.go
Normal file
505
svc/routes/product_pricing_history.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
492
svc/routes/product_pricing_price_list_export.go
Normal file
492
svc/routes/product_pricing_price_list_export.go
Normal 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())
|
||||
}
|
||||
}
|
||||
1195
svc/routes/product_pricing_save.go
Normal file
1195
svc/routes/product_pricing_save.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user