Merge remote-tracking branch 'origin/master'
This commit is contained in:
50
svc/main.go
50
svc/main.go
@@ -805,6 +805,41 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"pricing", "view",
|
||||
wrapV3(http.HandlerFunc(routes.ExportAllProductPricingHandler)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/price-list/export-excel", "POST",
|
||||
"pricing", "view",
|
||||
wrapV3(http.HandlerFunc(routes.ExportProductPriceListExcelHandler(pgDB))),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/price-list/export-pdf", "POST",
|
||||
"pricing", "view",
|
||||
wrapV3(http.HandlerFunc(routes.ExportProductPriceListPDFHandler(pgDB))),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/calculate-snapshots", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostProductPricingCalculateSnapshotsHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/{code}/price-history", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPricingHistoryHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/{code}/price-history/delete-latest", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostDeleteLatestProductPriceHistoryHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/{code}/price-history/delete-selected", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostDeleteSelectedProductPriceHistoryHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/products/save", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/brand-classification/lookups", "GET",
|
||||
"pricing", "view",
|
||||
@@ -830,6 +865,16 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"pricing", "update",
|
||||
wrapV3(routes.SetBrandGroupsBulkHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/brand-group-currency", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetBrandGroupCurrencyHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/brand-group-currency/bulk-save", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.SaveBrandGroupCurrencyHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/pricing-rules", "GET",
|
||||
"pricing", "view",
|
||||
@@ -1162,6 +1207,9 @@ func main() {
|
||||
if err := queries.EnsurePricingParameterTables(pgDB); err != nil {
|
||||
log.Println("mk_urunpricingprmtr bootstrap failed:", err)
|
||||
}
|
||||
if err := queries.EnsurePricingCalcInfraTables(pgDB); err != nil {
|
||||
log.Println("pricing calc infra bootstrap failed:", err)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// ✉️ MAILER INIT
|
||||
@@ -1184,6 +1232,8 @@ func main() {
|
||||
startTranslationSyncScheduler(pgDB, db.MssqlDB)
|
||||
startBrandSyncScheduler(pgDB, db.MssqlDB)
|
||||
startPricingParameterSyncScheduler(pgDB, db.MssqlDB)
|
||||
startProductPricingFxDeltaScheduler(pgDB)
|
||||
startProductPricingFxFullScheduler(pgDB)
|
||||
|
||||
handler := enableCORS(
|
||||
middlewares.GlobalAuthMiddleware(
|
||||
|
||||
123
svc/product_pricing_fx_delta_scheduler.go
Normal file
123
svc/product_pricing_fx_delta_scheduler.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/queries"
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
func startProductPricingFxDeltaScheduler(pgDB *sql.DB) {
|
||||
enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_PRICING_FX_DELTA_ENABLED")))
|
||||
if enabled == "0" || enabled == "false" || enabled == "off" {
|
||||
log.Println("Product pricing FX delta scheduler disabled")
|
||||
return
|
||||
}
|
||||
if pgDB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
intervalMin := 1
|
||||
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_DELTA_INTERVAL_MIN")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 1 {
|
||||
intervalMin = parsed
|
||||
}
|
||||
}
|
||||
batchSize := 200
|
||||
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_DELTA_BATCH_SIZE")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 10 && parsed <= 2000 {
|
||||
batchSize = parsed
|
||||
}
|
||||
}
|
||||
|
||||
var running int32 = 0
|
||||
|
||||
runOnce := func(reason string) {
|
||||
if db.PgDB == nil {
|
||||
return
|
||||
}
|
||||
if !atomic.CompareAndSwapInt32(&running, 0, 1) {
|
||||
log.Printf("[PricingFxDelta] skip (%s): already running", reason)
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&running, 0)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
totalClaimed := 0
|
||||
totalWritten := 0
|
||||
for {
|
||||
// Claim a batch.
|
||||
tx, err := pgDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
log.Printf("[PricingFxDelta] begin_tx_error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
items, err := queries.ClaimPriceRecalcQueue(ctx, tx, batchSize)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
log.Printf("[PricingFxDelta] claim_error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[PricingFxDelta] claim_commit_error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
totalClaimed += len(items)
|
||||
codes := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
if it.ProductCode != "" {
|
||||
codes = append(codes, it.ProductCode)
|
||||
}
|
||||
}
|
||||
|
||||
written, _, err := queries.PublishDerivedPricesFromAnchor(ctx, pgDB, codes, "", false)
|
||||
if err != nil {
|
||||
// Mark all failed.
|
||||
tx2, _ := pgDB.BeginTx(ctx, nil)
|
||||
if tx2 != nil {
|
||||
for _, it := range items {
|
||||
_ = queries.MarkPriceRecalcQueueFailed(ctx, tx2, it.ID, it.Attempts, err.Error())
|
||||
}
|
||||
_ = tx2.Commit()
|
||||
}
|
||||
log.Printf("[PricingFxDelta] publish_error (%s): claimed=%d err=%v", reason, len(items), err)
|
||||
return
|
||||
}
|
||||
totalWritten += written
|
||||
|
||||
// Mark all done (even if some were skipped due to missing anchor).
|
||||
tx3, _ := pgDB.BeginTx(ctx, nil)
|
||||
if tx3 != nil {
|
||||
for _, it := range items {
|
||||
_ = queries.MarkPriceRecalcQueueDone(ctx, tx3, it.ID)
|
||||
}
|
||||
_ = tx3.Commit()
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[PricingFxDelta] ok (%s): claimed=%d sdprc_written=%d interval_min=%d batch_size=%d", reason, totalClaimed, totalWritten, intervalMin, batchSize)
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
runOnce("startup")
|
||||
|
||||
ticker := time.NewTicker(time.Duration(intervalMin) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runOnce("scheduled")
|
||||
}
|
||||
}()
|
||||
}
|
||||
148
svc/product_pricing_fx_full_scheduler.go
Normal file
148
svc/product_pricing_fx_full_scheduler.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/queries"
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Weekly full FX publish job:
|
||||
// - Runs once every Monday at a configured local time.
|
||||
// - Recomputes derived currencies from anchor tiers and writes to sdprc for all products in mk_price_snapshot.
|
||||
func startProductPricingFxFullScheduler(pgDB *sql.DB) {
|
||||
enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_PRICING_FX_FULL_ENABLED")))
|
||||
// Be conservative: require explicit opt-in.
|
||||
if enabled != "1" && enabled != "true" && enabled != "on" && enabled != "yes" {
|
||||
log.Println("Product pricing FX full scheduler disabled (set PRODUCT_PRICING_FX_FULL_ENABLED=1 to enable)")
|
||||
return
|
||||
}
|
||||
if pgDB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Default: Monday 06:00 local time.
|
||||
runHH := 6
|
||||
runMM := 0
|
||||
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_FULL_HHMM")); raw != "" {
|
||||
parts := strings.Split(raw, ":")
|
||||
if len(parts) == 2 {
|
||||
if h, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && h >= 0 && h <= 23 {
|
||||
runHH = h
|
||||
}
|
||||
if m, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && m >= 0 && m <= 59 {
|
||||
runMM = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeBatch := 1000
|
||||
if raw := strings.TrimSpace(os.Getenv("PRODUCT_PRICING_FX_FULL_CODE_BATCH")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n >= 100 && n <= 5000 {
|
||||
codeBatch = n
|
||||
}
|
||||
}
|
||||
|
||||
var running int32 = 0
|
||||
|
||||
runOnce := func(reason string) {
|
||||
if db.PgDB == nil {
|
||||
return
|
||||
}
|
||||
if !atomic.CompareAndSwapInt32(&running, 0, 1) {
|
||||
log.Printf("[PricingFxFull] skip (%s): already running", reason)
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&running, 0)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour)
|
||||
defer cancel()
|
||||
|
||||
totalCodes := 0
|
||||
totalWritten := 0
|
||||
totalSkipped := 0
|
||||
|
||||
lastCode := ""
|
||||
for {
|
||||
rows, err := pgDB.QueryContext(ctx, `
|
||||
SELECT product_code
|
||||
FROM mk_price_snapshot
|
||||
WHERE COALESCE(NULLIF(BTRIM(product_code), ''), '') <> ''
|
||||
AND product_code > $1
|
||||
GROUP BY product_code
|
||||
ORDER BY product_code
|
||||
LIMIT $2
|
||||
`, lastCode, codeBatch)
|
||||
if err != nil {
|
||||
log.Printf("[PricingFxFull] list_codes_error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
|
||||
codes := make([]string, 0, codeBatch)
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
_ = rows.Close()
|
||||
log.Printf("[PricingFxFull] scan_code_error (%s): %v", reason, err)
|
||||
return
|
||||
}
|
||||
c = strings.TrimSpace(c)
|
||||
if c != "" {
|
||||
codes = append(codes, c)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
if len(codes) == 0 {
|
||||
break
|
||||
}
|
||||
lastCode = codes[len(codes)-1]
|
||||
|
||||
// Force FX refresh on the weekly run so Monday picks up the latest rates.
|
||||
written, skipped, err := queries.PublishDerivedPricesFromAnchor(ctx, pgDB, codes, "", true)
|
||||
if err != nil {
|
||||
log.Printf("[PricingFxFull] publish_error (%s): codes=%d err=%v", reason, len(codes), err)
|
||||
return
|
||||
}
|
||||
totalCodes += len(codes)
|
||||
totalWritten += written
|
||||
totalSkipped += skipped
|
||||
}
|
||||
|
||||
log.Printf("[PricingFxFull] ok (%s): products=%d sdprc_written=%d skipped=%d weekday=%d hhmm=%02d:%02d",
|
||||
reason, totalCodes, totalWritten, totalSkipped, int(time.Now().Weekday()), runHH, runMM)
|
||||
}
|
||||
|
||||
nextRun := func(now time.Time) time.Time {
|
||||
loc := now.Location()
|
||||
base := time.Date(now.Year(), now.Month(), now.Day(), runHH, runMM, 0, 0, loc)
|
||||
daysUntilMon := (int(time.Monday) - int(now.Weekday()) + 7) % 7
|
||||
candidate := base.AddDate(0, 0, daysUntilMon)
|
||||
// If today is Monday but the time has passed, schedule next Monday.
|
||||
if !candidate.After(now) {
|
||||
candidate = candidate.AddDate(0, 0, 7)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
for {
|
||||
now := time.Now()
|
||||
n := nextRun(now)
|
||||
d := time.Until(n)
|
||||
if d < 0 {
|
||||
d = time.Minute
|
||||
}
|
||||
log.Printf("[PricingFxFull] scheduled next_at=%s in=%s", n.Format(time.RFC3339), d.Round(time.Second))
|
||||
time.Sleep(d)
|
||||
runOnce("weekly")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type BrandGroupOption struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AnchorMode string `json:"anchor_mode"`
|
||||
}
|
||||
|
||||
func EnsureBrandClassificationTables(pg *sql.DB) error {
|
||||
@@ -41,10 +42,15 @@ CREATE TABLE IF NOT EXISTS mk_brandgrp (
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
anchor_mode TEXT NOT NULL DEFAULT 'USD',
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`ALTER TABLE mk_brandgrp ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE mk_brandgrp ADD COLUMN IF NOT EXISTS anchor_mode TEXT NOT NULL DEFAULT 'USD'`,
|
||||
`UPDATE mk_brandgrp SET anchor_mode='USD' WHERE COALESCE(NULLIF(BTRIM(anchor_mode), ''), '') = ''`,
|
||||
`ALTER TABLE mk_brandgrp DROP CONSTRAINT IF EXISTS ck_mk_brandgrp_anchor_mode`,
|
||||
`ALTER TABLE mk_brandgrp ADD CONSTRAINT ck_mk_brandgrp_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))`,
|
||||
`
|
||||
INSERT INTO mk_brandgrp (id, code, title, description, sort_order)
|
||||
VALUES
|
||||
@@ -74,7 +80,7 @@ CREATE TABLE IF NOT EXISTS mk_brandgrpmatch (
|
||||
}
|
||||
|
||||
func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error) {
|
||||
rows, err := pg.QueryContext(ctx, `SELECT id, code, title, description FROM mk_brandgrp ORDER BY sort_order, id`)
|
||||
rows, err := pg.QueryContext(ctx, `SELECT id, code, title, description, anchor_mode FROM mk_brandgrp ORDER BY sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -82,17 +88,57 @@ func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error
|
||||
out := make([]BrandGroupOption, 0, 8)
|
||||
for rows.Next() {
|
||||
var o BrandGroupOption
|
||||
if err := rows.Scan(&o.ID, &o.Code, &o.Title, &o.Description); err != nil {
|
||||
if err := rows.Scan(&o.ID, &o.Code, &o.Title, &o.Description, &o.AnchorMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.Code = strings.TrimSpace(o.Code)
|
||||
o.Title = strings.TrimSpace(o.Title)
|
||||
o.Description = strings.TrimSpace(o.Description)
|
||||
o.AnchorMode = strings.ToUpper(strings.TrimSpace(o.AnchorMode))
|
||||
if o.AnchorMode == "" {
|
||||
o.AnchorMode = "USD"
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func SetBrandGroupAnchorMode(ctx context.Context, tx *sql.Tx, grpID int, anchorMode string) error {
|
||||
anchorMode = strings.ToUpper(strings.TrimSpace(anchorMode))
|
||||
if anchorMode == "" {
|
||||
anchorMode = "USD"
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_brandgrp
|
||||
SET anchor_mode=$2
|
||||
WHERE id=$1
|
||||
`, grpID, anchorMode)
|
||||
return err
|
||||
}
|
||||
|
||||
func SyncPricingRuleAnchorModesByGroup(ctx context.Context, tx *sql.Tx, grpID int, anchorMode string) error {
|
||||
anchorMode = strings.ToUpper(strings.TrimSpace(anchorMode))
|
||||
if anchorMode == "" {
|
||||
anchorMode = "USD"
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_pricing_rule r
|
||||
SET anchor_mode=$2,
|
||||
updated_at=now()
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM mk_brandgrp g
|
||||
JOIN LATERAL unnest(r.brand_group) bg(value) ON TRUE
|
||||
WHERE g.id=$1
|
||||
AND (
|
||||
UPPER(BTRIM(bg.value)) = UPPER(BTRIM(g.code))
|
||||
OR UPPER(BTRIM(bg.value)) = UPPER(BTRIM(g.title))
|
||||
)
|
||||
)
|
||||
`, grpID, anchorMode)
|
||||
return err
|
||||
}
|
||||
|
||||
func ListBrandsWithGroups(ctx context.Context, pg *sql.DB, q string, limit int) ([]BrandRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5000
|
||||
|
||||
679
svc/queries/pricing_calc_engine.go
Normal file
679
svc/queries/pricing_calc_engine.go
Normal file
@@ -0,0 +1,679 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/models"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PricingFxRateCacheRow struct {
|
||||
RateDate string `json:"rate_date"`
|
||||
UsdTry float64 `json:"usd_try"`
|
||||
EurTry float64 `json:"eur_try"`
|
||||
UsdEur float64 `json:"usd_eur"`
|
||||
}
|
||||
|
||||
type ProductPricingSnapshotCalcRequest struct {
|
||||
ProductCodes []string
|
||||
Filters ProductPricingFilters
|
||||
RateDate string
|
||||
ForceFxRefresh bool
|
||||
}
|
||||
|
||||
type ProductPricingSnapshotCalcResult struct {
|
||||
RateDate string `json:"rate_date"`
|
||||
UsdTry float64 `json:"usd_try"`
|
||||
EurTry float64 `json:"eur_try"`
|
||||
UsdEur float64 `json:"usd_eur"`
|
||||
Requested int `json:"requested"`
|
||||
Calculated int `json:"calculated"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
type ProductPricingSnapshotPreviewRow struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
AnchorMode string `json:"anchor_mode"`
|
||||
BasePriceUsd float64 `json:"base_price_usd"`
|
||||
BasePriceTry float64 `json:"base_price_try"`
|
||||
USD1 float64 `json:"usd1"`
|
||||
USD2 float64 `json:"usd2"`
|
||||
USD3 float64 `json:"usd3"`
|
||||
USD4 float64 `json:"usd4"`
|
||||
USD5 float64 `json:"usd5"`
|
||||
USD6 float64 `json:"usd6"`
|
||||
EUR1 float64 `json:"eur1"`
|
||||
EUR2 float64 `json:"eur2"`
|
||||
EUR3 float64 `json:"eur3"`
|
||||
EUR4 float64 `json:"eur4"`
|
||||
EUR5 float64 `json:"eur5"`
|
||||
EUR6 float64 `json:"eur6"`
|
||||
TRY1 float64 `json:"try1"`
|
||||
TRY2 float64 `json:"try2"`
|
||||
TRY3 float64 `json:"try3"`
|
||||
TRY4 float64 `json:"try4"`
|
||||
TRY5 float64 `json:"try5"`
|
||||
TRY6 float64 `json:"try6"`
|
||||
}
|
||||
|
||||
type ProductPricingSnapshotPreviewResult struct {
|
||||
RateDate string `json:"rate_date"`
|
||||
UsdTry float64 `json:"usd_try"`
|
||||
EurTry float64 `json:"eur_try"`
|
||||
UsdEur float64 `json:"usd_eur"`
|
||||
Requested int `json:"requested"`
|
||||
Calculated int `json:"calculated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Rows []ProductPricingSnapshotPreviewRow `json:"rows"`
|
||||
}
|
||||
|
||||
func resolvePricingFxRateByDate(ctx context.Context, pg *sql.DB, rateDate string, forceRefresh bool, persist bool) (PricingFxRateCacheRow, error) {
|
||||
var out PricingFxRateCacheRow
|
||||
rateDate = normalizeCalcDate(rateDate)
|
||||
if rateDate == "" {
|
||||
rateDate = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
if !forceRefresh {
|
||||
err := pg.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
TO_CHAR(rate_date, 'YYYY-MM-DD'),
|
||||
usd_try::float8,
|
||||
eur_try::float8,
|
||||
usd_eur::float8
|
||||
FROM mk_fx_rate_cache
|
||||
WHERE rate_date=$1::date
|
||||
`, rateDate).Scan(&out.RateDate, &out.UsdTry, &out.EurTry, &out.UsdEur)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
if db.MssqlDB == nil {
|
||||
return out, fmt.Errorf("mssql pricing db not available")
|
||||
}
|
||||
row, err := GetProductionHasCostDetailExchangeRatesByDate(ctx, db.MssqlDB, rateDate)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var (
|
||||
rateDateResolved string
|
||||
usdTry float64
|
||||
eurTry float64
|
||||
gbpIgnored float64
|
||||
)
|
||||
if err := row.Scan(&rateDateResolved, &usdTry, &eurTry, &gbpIgnored); err != nil {
|
||||
return out, err
|
||||
}
|
||||
rateDateResolved = normalizeCalcDate(rateDateResolved)
|
||||
if rateDateResolved == "" {
|
||||
rateDateResolved = rateDate
|
||||
}
|
||||
usdEur := 0.0
|
||||
if usdTry > 0 && eurTry > 0 {
|
||||
usdEur = roundCalcValue(usdTry / eurTry)
|
||||
}
|
||||
|
||||
if persist {
|
||||
if _, err := pg.ExecContext(ctx, `
|
||||
INSERT INTO mk_fx_rate_cache (
|
||||
rate_date, usd_try, eur_try, usd_eur, source_system, source_updated_at, created_at, updated_at
|
||||
)
|
||||
VALUES ($1::date, $2, $3, $4, 'MSSQL', now(), now(), now())
|
||||
ON CONFLICT (rate_date)
|
||||
DO UPDATE SET
|
||||
usd_try=EXCLUDED.usd_try,
|
||||
eur_try=EXCLUDED.eur_try,
|
||||
usd_eur=EXCLUDED.usd_eur,
|
||||
source_system=EXCLUDED.source_system,
|
||||
source_updated_at=EXCLUDED.source_updated_at,
|
||||
updated_at=now()
|
||||
`, rateDateResolved, usdTry, eurTry, usdEur); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
out = PricingFxRateCacheRow{
|
||||
RateDate: rateDateResolved,
|
||||
UsdTry: usdTry,
|
||||
EurTry: eurTry,
|
||||
UsdEur: usdEur,
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func SyncPricingFxRateCacheByDate(ctx context.Context, pg *sql.DB, rateDate string, forceRefresh bool) (PricingFxRateCacheRow, error) {
|
||||
return resolvePricingFxRateByDate(ctx, pg, rateDate, forceRefresh, true)
|
||||
}
|
||||
|
||||
func CalculateProductPricingSnapshots(ctx context.Context, pg *sql.DB, req ProductPricingSnapshotCalcRequest) (ProductPricingSnapshotCalcResult, error) {
|
||||
var result ProductPricingSnapshotCalcResult
|
||||
rateRow, err := resolvePricingFxRateByDate(ctx, pg, req.RateDate, req.ForceFxRefresh, true)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.RateDate = rateRow.RateDate
|
||||
result.UsdTry = rateRow.UsdTry
|
||||
result.EurTry = rateRow.EurTry
|
||||
result.UsdEur = rateRow.UsdEur
|
||||
if rateRow.UsdTry <= 0 || rateRow.EurTry <= 0 || rateRow.UsdEur <= 0 {
|
||||
return result, fmt.Errorf("invalid fx rates for date %s", rateRow.RateDate)
|
||||
}
|
||||
|
||||
filters := req.Filters
|
||||
if len(req.ProductCodes) > 0 {
|
||||
filters.ProductCode = dedupeTrimmedStrings(req.ProductCodes)
|
||||
}
|
||||
|
||||
rows, err := GetAllProductPricingRows(ctx, 1000, filters, "productCode", false)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Requested = len(rows)
|
||||
if len(rows) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
ruleRows, err := ListPricingParameterRules(ctx, pg, PricingRuleOptionFilters{})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
rulesByScope := make(map[string]PricingParameterRuleRow, len(ruleRows))
|
||||
for _, item := range ruleRows {
|
||||
rulesByScope[item.ScopeKey] = item
|
||||
}
|
||||
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, product := range rows {
|
||||
scopeKey := pricingParameterScopeKey(pricingParameterRow{
|
||||
AskiliYan: strings.TrimSpace(product.AskiliYan),
|
||||
Kategori: strings.TrimSpace(product.Kategori),
|
||||
UrunIlkGrubu: strings.TrimSpace(product.UrunIlkGrubu),
|
||||
UrunAnaGrubu: strings.TrimSpace(product.UrunAnaGrubu),
|
||||
UrunAltGrubu: strings.TrimSpace(product.UrunAltGrubu),
|
||||
Icerik: strings.TrimSpace(product.Icerik),
|
||||
Marka: strings.TrimSpace(product.Marka),
|
||||
BrandCode: strings.TrimSpace(product.BrandCode),
|
||||
BrandGroupSec: strings.TrimSpace(product.BrandGroupSec),
|
||||
})
|
||||
ruleItem, ok := rulesByScope[scopeKey]
|
||||
if !ok || ruleItem.Rule == nil {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if !ruleItem.Rule.CalcEnabled || !ruleItem.Rule.IsActive {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
snapshot, ok := buildPricingSnapshotRow(product, ruleItem, rateRow)
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if err := upsertPricingSnapshot(ctx, tx, snapshot); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Calculated++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func PreviewProductPricingSnapshots(ctx context.Context, pg *sql.DB, req ProductPricingSnapshotCalcRequest) (ProductPricingSnapshotPreviewResult, error) {
|
||||
var result ProductPricingSnapshotPreviewResult
|
||||
rateRow, err := resolvePricingFxRateByDate(ctx, pg, req.RateDate, req.ForceFxRefresh, false)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.RateDate = rateRow.RateDate
|
||||
result.UsdTry = rateRow.UsdTry
|
||||
result.EurTry = rateRow.EurTry
|
||||
result.UsdEur = rateRow.UsdEur
|
||||
if rateRow.UsdTry <= 0 || rateRow.EurTry <= 0 || rateRow.UsdEur <= 0 {
|
||||
return result, fmt.Errorf("invalid fx rates for date %s", rateRow.RateDate)
|
||||
}
|
||||
|
||||
filters := req.Filters
|
||||
if len(req.ProductCodes) > 0 {
|
||||
filters.ProductCode = dedupeTrimmedStrings(req.ProductCodes)
|
||||
}
|
||||
|
||||
rows, err := GetAllProductPricingRows(ctx, 1000, filters, "productCode", false)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Requested = len(rows)
|
||||
if len(rows) == 0 {
|
||||
result.Rows = []ProductPricingSnapshotPreviewRow{}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
ruleRows, err := ListPricingParameterRules(ctx, pg, PricingRuleOptionFilters{})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
rulesByScope := make(map[string]PricingParameterRuleRow, len(ruleRows))
|
||||
for _, item := range ruleRows {
|
||||
rulesByScope[item.ScopeKey] = item
|
||||
}
|
||||
|
||||
outRows := make([]ProductPricingSnapshotPreviewRow, 0, len(rows))
|
||||
for _, product := range rows {
|
||||
scopeKey := pricingParameterScopeKey(pricingParameterRow{
|
||||
AskiliYan: strings.TrimSpace(product.AskiliYan),
|
||||
Kategori: strings.TrimSpace(product.Kategori),
|
||||
UrunIlkGrubu: strings.TrimSpace(product.UrunIlkGrubu),
|
||||
UrunAnaGrubu: strings.TrimSpace(product.UrunAnaGrubu),
|
||||
UrunAltGrubu: strings.TrimSpace(product.UrunAltGrubu),
|
||||
Icerik: strings.TrimSpace(product.Icerik),
|
||||
Marka: strings.TrimSpace(product.Marka),
|
||||
BrandCode: strings.TrimSpace(product.BrandCode),
|
||||
BrandGroupSec: strings.TrimSpace(product.BrandGroupSec),
|
||||
})
|
||||
ruleItem, ok := rulesByScope[scopeKey]
|
||||
if !ok || ruleItem.Rule == nil {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if !ruleItem.Rule.CalcEnabled || !ruleItem.Rule.IsActive {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
snapshot, ok := buildPricingSnapshotRow(product, ruleItem, rateRow)
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
outRows = append(outRows, previewRowFromSnapshot(snapshot))
|
||||
result.Calculated++
|
||||
}
|
||||
result.Rows = outRows
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type pricingSnapshotRow struct {
|
||||
ProductCode string
|
||||
PricingParameterID int64
|
||||
RuleID string
|
||||
StrategyCode string
|
||||
AnchorMode string
|
||||
FxDate string
|
||||
CostDate string
|
||||
BasePriceTry float64
|
||||
BasePriceUsd float64
|
||||
Try [6]float64
|
||||
Usd [6]float64
|
||||
Eur [6]float64
|
||||
CalcHash string
|
||||
}
|
||||
|
||||
func buildPricingSnapshotRow(product models.ProductPricing, ruleItem PricingParameterRuleRow, fx PricingFxRateCacheRow) (pricingSnapshotRow, bool) {
|
||||
var out pricingSnapshotRow
|
||||
rule := ruleItem.Rule
|
||||
if rule == nil {
|
||||
return out, false
|
||||
}
|
||||
|
||||
anchorMode := strings.ToUpper(strings.TrimSpace(rule.AnchorMode))
|
||||
if anchorMode != "TRY" && anchorMode != "USD" {
|
||||
anchorMode = "USD"
|
||||
}
|
||||
strategyCode := strings.ToUpper(strings.TrimSpace(rule.StrategyCode))
|
||||
if strategyCode != "CORE" && strategyCode != "PREMIUM" && strategyCode != "SARTORIAL" {
|
||||
strategyCode = strings.ToUpper(strings.TrimSpace(product.BrandGroupSec))
|
||||
}
|
||||
if strategyCode != "CORE" && strategyCode != "PREMIUM" && strategyCode != "SARTORIAL" {
|
||||
strategyCode = "CORE"
|
||||
}
|
||||
|
||||
costUSD := roundCalcValue(product.CostPrice)
|
||||
if costUSD <= 0 {
|
||||
return out, false
|
||||
}
|
||||
|
||||
baseUSD := 0.0
|
||||
baseTRY := 0.0
|
||||
switch anchorMode {
|
||||
case "TRY":
|
||||
if rule.TryBase > 0 {
|
||||
baseTRY = roundCalcValue(costUSD * fx.UsdTry * rule.TryBase)
|
||||
} else if product.BasePriceTry > 0 {
|
||||
baseTRY = roundCalcValue(product.BasePriceTry)
|
||||
} else if product.BasePriceUsd > 0 {
|
||||
baseTRY = roundCalcValue(product.BasePriceUsd * fx.UsdTry)
|
||||
} else if rule.UsdBase > 0 {
|
||||
baseTRY = roundCalcValue(costUSD * rule.UsdBase * fx.UsdTry)
|
||||
}
|
||||
if baseTRY <= 0 {
|
||||
return out, false
|
||||
}
|
||||
baseUSD = roundCalcValue(baseTRY / fx.UsdTry)
|
||||
default:
|
||||
if rule.UsdBase > 0 {
|
||||
baseUSD = roundCalcValue(costUSD * rule.UsdBase)
|
||||
} else if product.BasePriceUsd > 0 {
|
||||
baseUSD = roundCalcValue(product.BasePriceUsd)
|
||||
} else if product.BasePriceTry > 0 {
|
||||
baseUSD = roundCalcValue(product.BasePriceTry / fx.UsdTry)
|
||||
}
|
||||
if baseUSD <= 0 {
|
||||
return out, false
|
||||
}
|
||||
baseTRY = roundCalcValue(baseUSD * fx.UsdTry)
|
||||
}
|
||||
baseEUR := roundCalcValue(baseUSD * fx.UsdEur)
|
||||
|
||||
tryBaseForCalc := baseTRY
|
||||
usdBaseForCalc := baseUSD
|
||||
eurBaseForCalc := baseEUR
|
||||
if tryBaseForCalc <= 0 || usdBaseForCalc <= 0 || eurBaseForCalc <= 0 {
|
||||
return out, false
|
||||
}
|
||||
|
||||
tryMultipliers := [6]float64{rule.Try1, rule.Try2, rule.Try3, rule.Try4, rule.Try5, rule.Try6}
|
||||
usdMultipliers := [6]float64{rule.Usd1, rule.Usd2, rule.Usd3, rule.Usd4, rule.Usd5, rule.Usd6}
|
||||
eurMultipliers := [6]float64{rule.Eur1, rule.Eur2, rule.Eur3, rule.Eur4, rule.Eur5, rule.Eur6}
|
||||
|
||||
prevTry := tryBaseForCalc
|
||||
prevUsd := usdBaseForCalc
|
||||
prevEur := eurBaseForCalc
|
||||
for i := 0; i < 6; i++ {
|
||||
tryRaw := prevTry * tryMultipliers[i]
|
||||
usdRaw := prevUsd * usdMultipliers[i]
|
||||
eurRaw := prevEur * eurMultipliers[i]
|
||||
|
||||
tryStep := rule.TryWholesaleStep
|
||||
usdStep := rule.UsdWholesaleStep
|
||||
eurStep := rule.EurWholesaleStep
|
||||
if i == 5 {
|
||||
out.Try[i] = applyRetailRounding(tryRaw, rule.TryWholesaleStep, rule.TryRetailStep, rule.TryRetailMode)
|
||||
out.Usd[i] = applyRetailRounding(usdRaw, rule.UsdWholesaleStep, rule.UsdRetailStep, rule.UsdRetailMode)
|
||||
out.Eur[i] = applyRetailRounding(eurRaw, rule.EurWholesaleStep, rule.EurRetailStep, rule.EurRetailMode)
|
||||
prevTry = out.Try[i]
|
||||
prevUsd = out.Usd[i]
|
||||
prevEur = out.Eur[i]
|
||||
continue
|
||||
}
|
||||
|
||||
out.Try[i] = roundUpStep(tryRaw, tryStep)
|
||||
out.Usd[i] = roundUpStep(usdRaw, usdStep)
|
||||
out.Eur[i] = roundUpStep(eurRaw, eurStep)
|
||||
prevTry = out.Try[i]
|
||||
prevUsd = out.Usd[i]
|
||||
prevEur = out.Eur[i]
|
||||
}
|
||||
|
||||
out.ProductCode = strings.TrimSpace(product.ProductCode)
|
||||
out.PricingParameterID = ruleItem.PricingParameterID
|
||||
out.RuleID = strings.TrimSpace(rule.ID)
|
||||
out.StrategyCode = strategyCode
|
||||
out.AnchorMode = anchorMode
|
||||
out.FxDate = fx.RateDate
|
||||
out.CostDate = normalizeCalcDate(product.LastCostingDate)
|
||||
out.BasePriceTry = baseTRY
|
||||
out.BasePriceUsd = baseUSD
|
||||
out.CalcHash = pricingSnapshotHash(out, fx)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func applyRetailRounding(raw, wholesaleStep, retailStep float64, retailMode string) float64 {
|
||||
baseRounded := roundUpStep(raw, wholesaleStep)
|
||||
mode := normalizeRetailMode(retailMode)
|
||||
switch mode {
|
||||
case "END_99":
|
||||
return roundUpToEnding(baseRounded, 99)
|
||||
case "END_49":
|
||||
return roundUpToEnding(baseRounded, 49)
|
||||
case "BAND_99":
|
||||
return roundUpToBandEnding(baseRounded, retailStep, 99)
|
||||
case "BAND_49":
|
||||
return roundUpToBandEnding(baseRounded, retailStep, 49)
|
||||
default:
|
||||
if retailStep > 0 {
|
||||
return roundUpStep(baseRounded, retailStep)
|
||||
}
|
||||
return baseRounded
|
||||
}
|
||||
}
|
||||
|
||||
func roundUpToEnding(value float64, ending int) float64 {
|
||||
value = roundCalcValue(value)
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
switch ending {
|
||||
case 99:
|
||||
return roundCalcValue(psychologicalEnding99(value))
|
||||
case 49:
|
||||
return roundCalcValue(psychologicalEnding49(value))
|
||||
default:
|
||||
whole := math.Floor(value + 1e-9)
|
||||
candidate := whole + (float64(ending) / 100.0)
|
||||
if candidate+1e-9 < value {
|
||||
candidate = whole + 1 + (float64(ending) / 100.0)
|
||||
}
|
||||
return roundCalcValue(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func roundUpToBandEnding(value, band float64, ending int) float64 {
|
||||
value = roundCalcValue(value)
|
||||
band = roundCalcValue(band)
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if band <= 0 {
|
||||
return roundUpToEnding(value, ending)
|
||||
}
|
||||
units := math.Ceil((value - 1e-9) / band)
|
||||
candidate := (units * band) - 1 + (float64(ending) / 100.0)
|
||||
if candidate+1e-9 < value {
|
||||
candidate = ((units + 1) * band) - 1 + (float64(ending) / 100.0)
|
||||
}
|
||||
return roundCalcValue(candidate)
|
||||
}
|
||||
|
||||
func psychologicalEnding99(value float64) float64 {
|
||||
whole := math.Floor(value + 1e-9)
|
||||
fraction := value - whole
|
||||
if fraction >= 0.90 {
|
||||
return whole + 0.99
|
||||
}
|
||||
return whole - 0.01
|
||||
}
|
||||
|
||||
func psychologicalEnding49(value float64) float64 {
|
||||
whole := math.Floor(value + 1e-9)
|
||||
fraction := value - whole
|
||||
if fraction >= 0.40 {
|
||||
return whole + 0.49
|
||||
}
|
||||
return whole - 0.51
|
||||
}
|
||||
|
||||
func upsertPricingSnapshot(ctx context.Context, tx *sql.Tx, row pricingSnapshotRow) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_price_snapshot (
|
||||
product_code, pricing_parameter_id, rule_id, strategy_code, anchor_mode, fx_date, cost_date,
|
||||
base_price_try, base_price_usd,
|
||||
try1, try2, try3, try4, try5, try6,
|
||||
usd1, usd2, usd3, usd4, usd5, usd6,
|
||||
eur1, eur2, eur3, eur4, eur5, eur6,
|
||||
calc_hash, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1,$2,NULLIF($3,'')::uuid,$4,$5,$6::date,NULLIF($7,'')::date,
|
||||
$8,$9,
|
||||
$10,$11,$12,$13,$14,$15,
|
||||
$16,$17,$18,$19,$20,$21,
|
||||
$22,$23,$24,$25,$26,$27,
|
||||
$28,now(),now()
|
||||
)
|
||||
ON CONFLICT (product_code, pricing_parameter_id)
|
||||
DO UPDATE SET
|
||||
rule_id=NULLIF(EXCLUDED.rule_id::text,'')::uuid,
|
||||
strategy_code=EXCLUDED.strategy_code,
|
||||
anchor_mode=EXCLUDED.anchor_mode,
|
||||
fx_date=EXCLUDED.fx_date,
|
||||
cost_date=EXCLUDED.cost_date,
|
||||
base_price_try=EXCLUDED.base_price_try,
|
||||
base_price_usd=EXCLUDED.base_price_usd,
|
||||
try1=EXCLUDED.try1,
|
||||
try2=EXCLUDED.try2,
|
||||
try3=EXCLUDED.try3,
|
||||
try4=EXCLUDED.try4,
|
||||
try5=EXCLUDED.try5,
|
||||
try6=EXCLUDED.try6,
|
||||
usd1=EXCLUDED.usd1,
|
||||
usd2=EXCLUDED.usd2,
|
||||
usd3=EXCLUDED.usd3,
|
||||
usd4=EXCLUDED.usd4,
|
||||
usd5=EXCLUDED.usd5,
|
||||
usd6=EXCLUDED.usd6,
|
||||
eur1=EXCLUDED.eur1,
|
||||
eur2=EXCLUDED.eur2,
|
||||
eur3=EXCLUDED.eur3,
|
||||
eur4=EXCLUDED.eur4,
|
||||
eur5=EXCLUDED.eur5,
|
||||
eur6=EXCLUDED.eur6,
|
||||
calc_hash=EXCLUDED.calc_hash,
|
||||
updated_at=now()
|
||||
`, row.ProductCode, row.PricingParameterID, row.RuleID, row.StrategyCode, row.AnchorMode, row.FxDate, row.CostDate,
|
||||
row.BasePriceTry, row.BasePriceUsd,
|
||||
row.Try[0], row.Try[1], row.Try[2], row.Try[3], row.Try[4], row.Try[5],
|
||||
row.Usd[0], row.Usd[1], row.Usd[2], row.Usd[3], row.Usd[4], row.Usd[5],
|
||||
row.Eur[0], row.Eur[1], row.Eur[2], row.Eur[3], row.Eur[4], row.Eur[5],
|
||||
row.CalcHash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func previewRowFromSnapshot(row pricingSnapshotRow) ProductPricingSnapshotPreviewRow {
|
||||
return ProductPricingSnapshotPreviewRow{
|
||||
ProductCode: row.ProductCode,
|
||||
AnchorMode: row.AnchorMode,
|
||||
BasePriceUsd: roundCalcValue(row.BasePriceUsd),
|
||||
BasePriceTry: roundCalcValue(row.BasePriceTry),
|
||||
USD1: roundCalcValue(row.Usd[0]),
|
||||
USD2: roundCalcValue(row.Usd[1]),
|
||||
USD3: roundCalcValue(row.Usd[2]),
|
||||
USD4: roundCalcValue(row.Usd[3]),
|
||||
USD5: roundCalcValue(row.Usd[4]),
|
||||
USD6: roundCalcValue(row.Usd[5]),
|
||||
EUR1: roundCalcValue(row.Eur[0]),
|
||||
EUR2: roundCalcValue(row.Eur[1]),
|
||||
EUR3: roundCalcValue(row.Eur[2]),
|
||||
EUR4: roundCalcValue(row.Eur[3]),
|
||||
EUR5: roundCalcValue(row.Eur[4]),
|
||||
EUR6: roundCalcValue(row.Eur[5]),
|
||||
TRY1: roundCalcValue(row.Try[0]),
|
||||
TRY2: roundCalcValue(row.Try[1]),
|
||||
TRY3: roundCalcValue(row.Try[2]),
|
||||
TRY4: roundCalcValue(row.Try[3]),
|
||||
TRY5: roundCalcValue(row.Try[4]),
|
||||
TRY6: roundCalcValue(row.Try[5]),
|
||||
}
|
||||
}
|
||||
|
||||
func roundUpStep(value, step float64) float64 {
|
||||
value = roundCalcValue(value)
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
step = roundCalcValue(step)
|
||||
if step <= 0 {
|
||||
return value
|
||||
}
|
||||
units := math.Ceil((value - 1e-9) / step)
|
||||
return roundCalcValue(units * step)
|
||||
}
|
||||
|
||||
func roundCalcValue(value float64) float64 {
|
||||
if !isFiniteCalc(value) {
|
||||
return 0
|
||||
}
|
||||
return math.Round(value*1_000_000) / 1_000_000
|
||||
}
|
||||
|
||||
func isFiniteCalc(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
func normalizeCalcDate(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if len(value) >= 10 {
|
||||
value = value[:10]
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", value); err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func dedupeTrimmedStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
val := strings.TrimSpace(raw)
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[val]; ok {
|
||||
continue
|
||||
}
|
||||
seen[val] = struct{}{}
|
||||
out = append(out, val)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func pricingSnapshotHash(row pricingSnapshotRow, fx PricingFxRateCacheRow) string {
|
||||
parts := []string{
|
||||
row.ProductCode,
|
||||
fmt.Sprintf("%d", row.PricingParameterID),
|
||||
row.RuleID,
|
||||
row.StrategyCode,
|
||||
row.AnchorMode,
|
||||
row.FxDate,
|
||||
row.CostDate,
|
||||
fmt.Sprintf("%.6f", row.BasePriceTry),
|
||||
fmt.Sprintf("%.6f", row.BasePriceUsd),
|
||||
fmt.Sprintf("%.6f", fx.UsdTry),
|
||||
fmt.Sprintf("%.6f", fx.EurTry),
|
||||
fmt.Sprintf("%.6f", fx.UsdEur),
|
||||
}
|
||||
for _, value := range row.Try {
|
||||
parts = append(parts, fmt.Sprintf("%.6f", value))
|
||||
}
|
||||
for _, value := range row.Usd {
|
||||
parts = append(parts, fmt.Sprintf("%.6f", value))
|
||||
}
|
||||
for _, value := range row.Eur {
|
||||
parts = append(parts, fmt.Sprintf("%.6f", value))
|
||||
}
|
||||
sum := md5.Sum([]byte(strings.Join(parts, string(rune(31)))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
188
svc/queries/pricing_calc_infra.go
Normal file
188
svc/queries/pricing_calc_infra.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func EnsurePricingCalcInfraTables(pg *sql.DB) error {
|
||||
stmts := []string{
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_fx_rate_cache (
|
||||
rate_date DATE PRIMARY KEY,
|
||||
usd_try NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur_try NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd_eur NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
source_system TEXT NOT NULL DEFAULT 'MSSQL',
|
||||
source_updated_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_fx_rate_cache_updated_at ON mk_fx_rate_cache (updated_at DESC)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_price_snapshot (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
product_code TEXT NOT NULL,
|
||||
pricing_parameter_id BIGINT REFERENCES mk_urunpricingprmtr(id) ON DELETE CASCADE,
|
||||
rule_id UUID REFERENCES mk_pricing_rule(id) ON DELETE SET NULL,
|
||||
strategy_code TEXT NOT NULL DEFAULT 'CORE',
|
||||
anchor_mode TEXT NOT NULL DEFAULT 'USD',
|
||||
fx_date DATE NOT NULL,
|
||||
cost_date DATE,
|
||||
base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try1 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try2 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try3 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try4 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try5 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
try6 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd1 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd2 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd3 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd4 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd5 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
usd6 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur1 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur2 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur3 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur4 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur5 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
eur6 NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
calc_hash TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_mk_price_snapshot_product_scope UNIQUE (product_code, pricing_parameter_id),
|
||||
CONSTRAINT ck_mk_price_snapshot_strategy_code CHECK (strategy_code IN ('CORE','PREMIUM','SARTORIAL')),
|
||||
CONSTRAINT ck_mk_price_snapshot_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_price_snapshot_rule ON mk_price_snapshot (rule_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_price_snapshot_updated_at ON mk_price_snapshot (updated_at DESC)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_price_target_map_pg (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
currency TEXT NOT NULL,
|
||||
level_no SMALLINT NOT NULL,
|
||||
sdprcgrp_id INTEGER,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_mk_price_target_map_pg UNIQUE (currency, level_no),
|
||||
CONSTRAINT ck_mk_price_target_map_pg_currency CHECK (currency IN ('TRY','USD','EUR')),
|
||||
CONSTRAINT ck_mk_price_target_map_pg_level_no CHECK (level_no BETWEEN 1 AND 6)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_price_target_map_pg_active ON mk_price_target_map_pg (is_active, currency, level_no)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_price_target_map_nebim (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
currency TEXT NOT NULL,
|
||||
level_no SMALLINT NOT NULL,
|
||||
price_group_code TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_mk_price_target_map_nebim UNIQUE (currency, level_no),
|
||||
CONSTRAINT ck_mk_price_target_map_nebim_currency CHECK (currency IN ('TRY','USD','EUR')),
|
||||
CONSTRAINT ck_mk_price_target_map_nebim_level_no CHECK (level_no BETWEEN 1 AND 6)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_price_target_map_nebim_active ON mk_price_target_map_nebim (is_active, currency, level_no)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_price_recalc_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
product_code TEXT NOT NULL,
|
||||
pricing_parameter_id BIGINT REFERENCES mk_urunpricingprmtr(id) ON DELETE SET NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMPTZ,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ck_mk_price_recalc_queue_status CHECK (status IN ('pending','processing','done','failed'))
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_price_recalc_queue_status ON mk_price_recalc_queue (status, available_at, queued_at)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_mk_price_recalc_queue_pending ON mk_price_recalc_queue (product_code, COALESCE(pricing_parameter_id, 0)) WHERE status IN ('pending','processing')`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_mmitem_dim_combo (
|
||||
product_code TEXT NOT NULL,
|
||||
dim1 INTEGER NOT NULL,
|
||||
dim3 INTEGER,
|
||||
dim3_key INTEGER GENERATED ALWAYS AS (COALESCE(dim3, 0)) STORED,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT pk_mk_mmitem_dim_combo PRIMARY KEY (product_code, dim1, dim3_key)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_mmitem_dim_combo_product ON mk_mmitem_dim_combo (product_code, updated_at DESC)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_dim_token_map (
|
||||
dim_column TEXT NOT NULL, -- dimval1 or dimval3
|
||||
token TEXT NOT NULL, -- normalized token (e.g. "001", "82", etc.)
|
||||
dim_id INTEGER NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT pk_mk_dim_token_map PRIMARY KEY (dim_column, token)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_dim_token_map_updated ON mk_dim_token_map (updated_at DESC)`,
|
||||
}
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if _, err := pg.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedPricingTargetMapRows(pg, "mk_price_target_map_pg", "sdprcgrp_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedPricingTargetMapRows(pg, "mk_price_target_map_nebim", "price_group_code"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Repair invalid/missing pg target mappings after manual edits or table resets.
|
||||
// sdprcgrp_id is expected to be 1..6 in this installation.
|
||||
if _, err := pg.Exec(`
|
||||
UPDATE mk_price_target_map_pg
|
||||
SET sdprcgrp_id = level_no,
|
||||
updated_at = now()
|
||||
WHERE is_active = TRUE
|
||||
AND (sdprcgrp_id IS NULL OR sdprcgrp_id NOT BETWEEN 1 AND 6)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedPricingTargetMapRows(pg *sql.DB, tableName string, valueColumn string) error {
|
||||
currencies := []string{"TRY", "USD", "EUR"}
|
||||
for _, currency := range currencies {
|
||||
for level := 1; level <= 6; level++ {
|
||||
stmt := fmt.Sprintf(`
|
||||
INSERT INTO %s (currency, level_no, %s, description, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, NULL, '', TRUE, now(), now())
|
||||
ON CONFLICT (currency, level_no) DO NOTHING
|
||||
`, tableName, valueColumn)
|
||||
// PG targets: default sdprcgrp_id = level_no (1..6). This keeps sdprc writes valid after resets.
|
||||
if tableName == "mk_price_target_map_pg" && valueColumn == "sdprcgrp_id" {
|
||||
stmt = fmt.Sprintf(`
|
||||
INSERT INTO %s (currency, level_no, %s, description, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $2, '', TRUE, now(), now())
|
||||
ON CONFLICT (currency, level_no) DO NOTHING
|
||||
`, tableName, valueColumn)
|
||||
}
|
||||
if valueColumn == "price_group_code" {
|
||||
stmt = fmt.Sprintf(`
|
||||
INSERT INTO %s (currency, level_no, %s, description, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, '', '', TRUE, now(), now())
|
||||
ON CONFLICT (currency, level_no) DO NOTHING
|
||||
`, tableName, valueColumn)
|
||||
}
|
||||
if _, err := pg.Exec(stmt, currency, level); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -196,7 +196,6 @@ SELECT
|
||||
icerik, marka, brand_code, brand_group_sec, scope_key
|
||||
FROM mk_urunpricingprmtr
|
||||
WHERE id=$1
|
||||
AND is_active=TRUE
|
||||
`, pricingParameterID).Scan(
|
||||
&p.AskiliYan,
|
||||
&p.Kategori,
|
||||
@@ -441,6 +440,12 @@ WHERE ProductAtt42 IN ('SERI', 'AKSESUAR')
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Serialize writes touching mk_urunpricingprmtr/mk_pricing_rule/mk_pricex/mk_priceroll
|
||||
// to avoid deadlocks with bulk-save/import flows.
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
CREATE TEMP TABLE tmp_urunpricingprmtr_sync (
|
||||
askili_yan TEXT NOT NULL,
|
||||
@@ -714,6 +719,17 @@ SELECT
|
||||
p.brand_code,
|
||||
p.brand_group_sec,
|
||||
COALESCE(r.id::text, ''),
|
||||
COALESCE(
|
||||
r.strategy_code,
|
||||
CASE
|
||||
WHEN UPPER(BTRIM(p.brand_group_sec)) IN ('CORE','PREMIUM','SARTORIAL') THEN UPPER(BTRIM(p.brand_group_sec))
|
||||
ELSE 'CORE'
|
||||
END
|
||||
),
|
||||
COALESCE(r.anchor_mode, bg.anchor_mode, 'USD'),
|
||||
COALESCE(r.calc_enabled, TRUE),
|
||||
COALESCE(r.publish_postgres, TRUE),
|
||||
COALESCE(r.publish_nebim, TRUE),
|
||||
COALESCE(r.is_active, TRUE),
|
||||
|
||||
COALESCE(tx.base_mult, 0)::float8,
|
||||
@@ -725,6 +741,7 @@ SELECT
|
||||
COALESCE(tx.m6, 0)::float8,
|
||||
COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8,
|
||||
COALESCE(NULLIF(tr.retail_step, 0), tr.step, 0)::float8,
|
||||
COALESCE(NULLIF(BTRIM(tr.retail_mode), ''), 'STEP'),
|
||||
|
||||
COALESCE(ux.base_mult, 0)::float8,
|
||||
COALESCE(ux.m1, 0)::float8,
|
||||
@@ -735,6 +752,7 @@ SELECT
|
||||
COALESCE(ux.m6, 0)::float8,
|
||||
COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8,
|
||||
COALESCE(NULLIF(ur.retail_step, 0), ur.step, 0)::float8,
|
||||
COALESCE(NULLIF(BTRIM(ur.retail_mode), ''), 'STEP'),
|
||||
|
||||
COALESCE(ex.base_mult, 0)::float8,
|
||||
COALESCE(ex.m1, 0)::float8,
|
||||
@@ -744,7 +762,8 @@ SELECT
|
||||
COALESCE(ex.m5, 0)::float8,
|
||||
COALESCE(ex.m6, 0)::float8,
|
||||
COALESCE(NULLIF(er.wholesale_step, 0), er.step, 0)::float8,
|
||||
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8
|
||||
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8,
|
||||
COALESCE(NULLIF(BTRIM(er.retail_mode), ''), 'STEP')
|
||||
FROM mk_urunpricingprmtr p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT latest_rule.*
|
||||
@@ -753,6 +772,14 @@ LEFT JOIN LATERAL (
|
||||
ORDER BY latest_rule.created_at DESC, latest_rule.updated_at DESC, latest_rule.id DESC
|
||||
LIMIT 1
|
||||
) r ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT g.anchor_mode
|
||||
FROM mk_brandgrp g
|
||||
WHERE UPPER(BTRIM(g.code)) = UPPER(BTRIM(p.brand_group_sec))
|
||||
OR UPPER(BTRIM(g.title)) = UPPER(BTRIM(p.brand_group_sec))
|
||||
ORDER BY g.id
|
||||
LIMIT 1
|
||||
) bg ON TRUE
|
||||
LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY'
|
||||
LEFT JOIN mk_pricex ux ON ux.rule_id = r.id AND ux.currency='USD'
|
||||
LEFT JOIN mk_pricex ex ON ex.rule_id = r.id AND ex.currency='EUR'
|
||||
@@ -790,13 +817,21 @@ ORDER BY
|
||||
&item.BrandCode,
|
||||
&item.BrandGroupSec,
|
||||
&rule.ID,
|
||||
&rule.StrategyCode,
|
||||
&rule.AnchorMode,
|
||||
&rule.CalcEnabled,
|
||||
&rule.PublishPostgres,
|
||||
&rule.PublishNebim,
|
||||
&rule.IsActive,
|
||||
&rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryWholesaleStep, &rule.TryRetailStep,
|
||||
&rule.UsdBase, &rule.Usd1, &rule.Usd2, &rule.Usd3, &rule.Usd4, &rule.Usd5, &rule.Usd6, &rule.UsdWholesaleStep, &rule.UsdRetailStep,
|
||||
&rule.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurWholesaleStep, &rule.EurRetailStep,
|
||||
&rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryWholesaleStep, &rule.TryRetailStep, &rule.TryRetailMode,
|
||||
&rule.UsdBase, &rule.Usd1, &rule.Usd2, &rule.Usd3, &rule.Usd4, &rule.Usd5, &rule.Usd6, &rule.UsdWholesaleStep, &rule.UsdRetailStep, &rule.UsdRetailMode,
|
||||
&rule.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurWholesaleStep, &rule.EurRetailStep, &rule.EurRetailMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule.TryRetailMode = normalizeRetailMode(rule.TryRetailMode)
|
||||
rule.UsdRetailMode = normalizeRetailMode(rule.UsdRetailMode)
|
||||
rule.EurRetailMode = normalizeRetailMode(rule.EurRetailMode)
|
||||
rule.PricingParameterID = item.PricingParameterID
|
||||
rule.AskiliYan = pricingParameterScopeValue(item.AskiliYan)
|
||||
rule.Kategori = pricingParameterScopeValue(item.Kategori)
|
||||
@@ -809,9 +844,7 @@ ORDER BY
|
||||
rule.BrandCode = pricingParameterScopeValue(item.BrandCode)
|
||||
rule.BrandGroupSec = pricingParameterScopeValue(item.BrandGroupSec)
|
||||
item.HasRule = strings.TrimSpace(rule.ID) != ""
|
||||
if item.HasRule {
|
||||
item.Rule = &rule
|
||||
}
|
||||
item.Rule = &rule
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
@@ -3,6 +3,7 @@ package queries
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -15,6 +16,22 @@ import (
|
||||
// - mk_pricex: per-currency multipliers (base + 1..6).
|
||||
// - mk_priceroll: per-currency rounding steps for wholesale (1-5) and retail (6+).
|
||||
|
||||
func normalizeRetailMode(v string) string {
|
||||
v = strings.ToUpper(strings.TrimSpace(v))
|
||||
switch v {
|
||||
case "", "STEP":
|
||||
return "STEP"
|
||||
case "END_99", "END_49", "BAND_99", "BAND_49":
|
||||
return v
|
||||
default:
|
||||
return "STEP"
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeRetailModeForRoute(v string) string {
|
||||
return normalizeRetailMode(v)
|
||||
}
|
||||
|
||||
func EnsurePricingRuleTables(pg *sql.DB) error {
|
||||
stmts := []string{
|
||||
`
|
||||
@@ -32,10 +49,26 @@ CREATE TABLE IF NOT EXISTS mk_pricing_rule (
|
||||
brand_code TEXT[] NOT NULL DEFAULT '{}'::text[],
|
||||
brand_group TEXT[] NOT NULL DEFAULT '{}'::text[],
|
||||
|
||||
strategy_code TEXT NOT NULL DEFAULT 'CORE',
|
||||
anchor_mode TEXT NOT NULL DEFAULT 'USD',
|
||||
calc_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
publish_postgres BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
publish_nebim BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS strategy_code TEXT NOT NULL DEFAULT 'CORE'`,
|
||||
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS anchor_mode TEXT NOT NULL DEFAULT 'USD'`,
|
||||
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS calc_enabled BOOLEAN NOT NULL DEFAULT TRUE`,
|
||||
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS publish_postgres BOOLEAN NOT NULL DEFAULT TRUE`,
|
||||
`ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS publish_nebim BOOLEAN NOT NULL DEFAULT TRUE`,
|
||||
`UPDATE mk_pricing_rule SET strategy_code='CORE' WHERE COALESCE(NULLIF(BTRIM(strategy_code), ''), '') = ''`,
|
||||
`UPDATE mk_pricing_rule SET anchor_mode='USD' WHERE COALESCE(NULLIF(BTRIM(anchor_mode), ''), '') = ''`,
|
||||
`ALTER TABLE mk_pricing_rule DROP CONSTRAINT IF EXISTS ck_mk_pricing_rule_strategy_code`,
|
||||
`ALTER TABLE mk_pricing_rule ADD CONSTRAINT ck_mk_pricing_rule_strategy_code CHECK (strategy_code IN ('CORE','PREMIUM','SARTORIAL'))`,
|
||||
`ALTER TABLE mk_pricing_rule DROP CONSTRAINT IF EXISTS ck_mk_pricing_rule_anchor_mode`,
|
||||
`ALTER TABLE mk_pricing_rule ADD CONSTRAINT ck_mk_pricing_rule_anchor_mode CHECK (anchor_mode IN ('TRY','USD'))`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_pricing_rule_active ON mk_pricing_rule (is_active)`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS mk_pricex (
|
||||
@@ -60,13 +93,16 @@ CREATE TABLE IF NOT EXISTS mk_priceroll (
|
||||
step NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
wholesale_step NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
retail_step NUMERIC(18,6) NOT NULL DEFAULT 0,
|
||||
retail_mode TEXT NOT NULL DEFAULT 'STEP',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (rule_id, currency)
|
||||
)`,
|
||||
`ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS wholesale_step NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS retail_step NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE mk_priceroll ADD COLUMN IF NOT EXISTS retail_mode TEXT NOT NULL DEFAULT 'STEP'`,
|
||||
`UPDATE mk_priceroll SET wholesale_step = step, retail_step = step WHERE step <> 0 AND wholesale_step = 0 AND retail_step = 0`,
|
||||
`UPDATE mk_priceroll SET retail_mode='STEP' WHERE COALESCE(NULLIF(BTRIM(retail_mode), ''), '') = ''`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_mk_priceroll_currency ON mk_priceroll (currency)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
@@ -92,7 +128,12 @@ type PricingRuleRow struct {
|
||||
BrandCode []string `json:"brand_code"`
|
||||
BrandGroupSec []string `json:"brand_group"`
|
||||
|
||||
IsActive bool `json:"is_active"`
|
||||
StrategyCode string `json:"strategy_code"`
|
||||
AnchorMode string `json:"anchor_mode"`
|
||||
CalcEnabled bool `json:"calc_enabled"`
|
||||
PublishPostgres bool `json:"publish_postgres"`
|
||||
PublishNebim bool `json:"publish_nebim"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
// multipliers/rolls are per currency
|
||||
TryBase float64 `json:"try_base"`
|
||||
@@ -104,6 +145,7 @@ type PricingRuleRow struct {
|
||||
Try6 float64 `json:"try6"`
|
||||
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
||||
TryRetailStep float64 `json:"try_retail_step"`
|
||||
TryRetailMode string `json:"try_retail_mode"`
|
||||
|
||||
UsdBase float64 `json:"usd_base"`
|
||||
Usd1 float64 `json:"usd1"`
|
||||
@@ -114,6 +156,7 @@ type PricingRuleRow struct {
|
||||
Usd6 float64 `json:"usd6"`
|
||||
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
||||
UsdRetailStep float64 `json:"usd_retail_step"`
|
||||
UsdRetailMode string `json:"usd_retail_mode"`
|
||||
|
||||
EurBase float64 `json:"eur_base"`
|
||||
Eur1 float64 `json:"eur1"`
|
||||
@@ -124,6 +167,7 @@ type PricingRuleRow struct {
|
||||
Eur6 float64 `json:"eur6"`
|
||||
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
||||
EurRetailStep float64 `json:"eur_retail_step"`
|
||||
EurRetailMode string `json:"eur_retail_mode"`
|
||||
}
|
||||
|
||||
type PricingRuleSaveItem struct {
|
||||
@@ -141,7 +185,12 @@ type PricingRuleSaveItem struct {
|
||||
BrandCode []string `json:"brand_code"`
|
||||
BrandGroupSec []string `json:"brand_group"`
|
||||
|
||||
IsActive bool `json:"is_active"`
|
||||
StrategyCode string `json:"strategy_code"`
|
||||
AnchorMode string `json:"anchor_mode"`
|
||||
CalcEnabled bool `json:"calc_enabled"`
|
||||
PublishPostgres bool `json:"publish_postgres"`
|
||||
PublishNebim bool `json:"publish_nebim"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
TryBase float64 `json:"try_base"`
|
||||
Try1 float64 `json:"try1"`
|
||||
@@ -152,6 +201,7 @@ type PricingRuleSaveItem struct {
|
||||
Try6 float64 `json:"try6"`
|
||||
TryWholesaleStep float64 `json:"try_wholesale_step"`
|
||||
TryRetailStep float64 `json:"try_retail_step"`
|
||||
TryRetailMode string `json:"try_retail_mode"`
|
||||
|
||||
UsdBase float64 `json:"usd_base"`
|
||||
Usd1 float64 `json:"usd1"`
|
||||
@@ -162,6 +212,7 @@ type PricingRuleSaveItem struct {
|
||||
Usd6 float64 `json:"usd6"`
|
||||
UsdWholesaleStep float64 `json:"usd_wholesale_step"`
|
||||
UsdRetailStep float64 `json:"usd_retail_step"`
|
||||
UsdRetailMode string `json:"usd_retail_mode"`
|
||||
|
||||
EurBase float64 `json:"eur_base"`
|
||||
Eur1 float64 `json:"eur1"`
|
||||
@@ -172,6 +223,174 @@ type PricingRuleSaveItem struct {
|
||||
Eur6 float64 `json:"eur6"`
|
||||
EurWholesaleStep float64 `json:"eur_wholesale_step"`
|
||||
EurRetailStep float64 `json:"eur_retail_step"`
|
||||
EurRetailMode string `json:"eur_retail_mode"`
|
||||
}
|
||||
|
||||
// BulkSavePricingRulesFast persists multipliers + rounding steps in a set-based way.
|
||||
// This is intentionally "dumb": it updates/creates a mk_pricing_rule row (latest by pricing_parameter_id)
|
||||
// and upserts mk_pricex/mk_priceroll for TRY/USD/EUR.
|
||||
func BulkSavePricingRulesFast(ctx context.Context, tx *sql.Tx, items []PricingRuleSaveItem) (int, error) {
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Notes:
|
||||
// - rule_id resolution:
|
||||
// 1) explicit id (if provided)
|
||||
// 2) latest rule for pricing_parameter_id (if provided)
|
||||
// 3) otherwise new UUID
|
||||
// - mk_pricing_rule has no unique constraint on pricing_parameter_id by design, so we target "latest" row.
|
||||
// - created_at uses default; updated_at is bumped on every save.
|
||||
q := `
|
||||
WITH input AS (
|
||||
SELECT *
|
||||
FROM jsonb_to_recordset($1::jsonb) AS x(
|
||||
id text,
|
||||
pricing_parameter_id bigint,
|
||||
calc_enabled boolean,
|
||||
publish_postgres boolean,
|
||||
publish_nebim boolean,
|
||||
is_active boolean,
|
||||
try_retail_mode text,
|
||||
usd_retail_mode text,
|
||||
eur_retail_mode text,
|
||||
|
||||
try_base float8, try1 float8, try2 float8, try3 float8, try4 float8, try5 float8, try6 float8,
|
||||
try_wholesale_step float8, try_retail_step float8,
|
||||
|
||||
usd_base float8, usd1 float8, usd2 float8, usd3 float8, usd4 float8, usd5 float8, usd6 float8,
|
||||
usd_wholesale_step float8, usd_retail_step float8,
|
||||
|
||||
eur_base float8, eur1 float8, eur2 float8, eur3 float8, eur4 float8, eur5 float8, eur6 float8,
|
||||
eur_wholesale_step float8, eur_retail_step float8
|
||||
)
|
||||
),
|
||||
norm AS (
|
||||
SELECT
|
||||
NULLIF(BTRIM(id), '') AS id_txt,
|
||||
COALESCE(pricing_parameter_id, 0) AS pricing_parameter_id,
|
||||
COALESCE(calc_enabled, TRUE) AS calc_enabled,
|
||||
COALESCE(publish_postgres, TRUE) AS publish_postgres,
|
||||
COALESCE(publish_nebim, TRUE) AS publish_nebim,
|
||||
COALESCE(is_active, TRUE) AS is_active,
|
||||
COALESCE(NULLIF(UPPER(BTRIM(try_retail_mode)), ''), 'STEP') AS try_retail_mode,
|
||||
COALESCE(NULLIF(UPPER(BTRIM(usd_retail_mode)), ''), 'STEP') AS usd_retail_mode,
|
||||
COALESCE(NULLIF(UPPER(BTRIM(eur_retail_mode)), ''), 'STEP') AS eur_retail_mode,
|
||||
|
||||
COALESCE(try_base, 0) AS try_base, COALESCE(try1, 0) AS try1, COALESCE(try2, 0) AS try2, COALESCE(try3, 0) AS try3, COALESCE(try4, 0) AS try4, COALESCE(try5, 0) AS try5, COALESCE(try6, 0) AS try6,
|
||||
COALESCE(try_wholesale_step, 0) AS try_wholesale_step, COALESCE(try_retail_step, 0) AS try_retail_step,
|
||||
|
||||
COALESCE(usd_base, 0) AS usd_base, COALESCE(usd1, 0) AS usd1, COALESCE(usd2, 0) AS usd2, COALESCE(usd3, 0) AS usd3, COALESCE(usd4, 0) AS usd4, COALESCE(usd5, 0) AS usd5, COALESCE(usd6, 0) AS usd6,
|
||||
COALESCE(usd_wholesale_step, 0) AS usd_wholesale_step, COALESCE(usd_retail_step, 0) AS usd_retail_step,
|
||||
|
||||
COALESCE(eur_base, 0) AS eur_base, COALESCE(eur1, 0) AS eur1, COALESCE(eur2, 0) AS eur2, COALESCE(eur3, 0) AS eur3, COALESCE(eur4, 0) AS eur4, COALESCE(eur5, 0) AS eur5, COALESCE(eur6, 0) AS eur6,
|
||||
COALESCE(eur_wholesale_step, 0) AS eur_wholesale_step, COALESCE(eur_retail_step, 0) AS eur_retail_step
|
||||
FROM input
|
||||
),
|
||||
resolved AS (
|
||||
SELECT
|
||||
COALESCE(
|
||||
NULLIF(id_txt, '')::uuid,
|
||||
latest.id,
|
||||
gen_random_uuid()
|
||||
) AS rule_id,
|
||||
pricing_parameter_id,
|
||||
calc_enabled,
|
||||
publish_postgres,
|
||||
publish_nebim,
|
||||
is_active,
|
||||
try_retail_mode,
|
||||
usd_retail_mode,
|
||||
eur_retail_mode,
|
||||
|
||||
try_base, try1, try2, try3, try4, try5, try6,
|
||||
try_wholesale_step, try_retail_step,
|
||||
usd_base, usd1, usd2, usd3, usd4, usd5, usd6,
|
||||
usd_wholesale_step, usd_retail_step,
|
||||
eur_base, eur1, eur2, eur3, eur4, eur5, eur6,
|
||||
eur_wholesale_step, eur_retail_step
|
||||
FROM norm n
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT r.id
|
||||
FROM mk_pricing_rule r
|
||||
WHERE r.pricing_parameter_id = n.pricing_parameter_id
|
||||
ORDER BY r.created_at DESC, r.updated_at DESC, r.id DESC
|
||||
LIMIT 1
|
||||
) latest ON (n.id_txt IS NULL AND n.pricing_parameter_id > 0)
|
||||
),
|
||||
upsert_rule AS (
|
||||
INSERT INTO mk_pricing_rule (
|
||||
id,
|
||||
pricing_parameter_id,
|
||||
calc_enabled,
|
||||
publish_postgres,
|
||||
publish_nebim,
|
||||
is_active,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
rule_id,
|
||||
NULLIF(pricing_parameter_id, 0),
|
||||
calc_enabled,
|
||||
publish_postgres,
|
||||
publish_nebim,
|
||||
is_active,
|
||||
now()
|
||||
FROM resolved
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
pricing_parameter_id = EXCLUDED.pricing_parameter_id,
|
||||
calc_enabled = EXCLUDED.calc_enabled,
|
||||
publish_postgres = EXCLUDED.publish_postgres,
|
||||
publish_nebim = EXCLUDED.publish_nebim,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = now()
|
||||
RETURNING id
|
||||
),
|
||||
upsert_pricex AS (
|
||||
INSERT INTO mk_pricex (rule_id, currency, base_mult, m1, m2, m3, m4, m5, m6, updated_at)
|
||||
SELECT rule_id, 'TRY', try_base, try1, try2, try3, try4, try5, try6, now() FROM resolved
|
||||
UNION ALL
|
||||
SELECT rule_id, 'USD', usd_base, usd1, usd2, usd3, usd4, usd5, usd6, now() FROM resolved
|
||||
UNION ALL
|
||||
SELECT rule_id, 'EUR', eur_base, eur1, eur2, eur3, eur4, eur5, eur6, now() FROM resolved
|
||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
base_mult = EXCLUDED.base_mult,
|
||||
m1 = EXCLUDED.m1,
|
||||
m2 = EXCLUDED.m2,
|
||||
m3 = EXCLUDED.m3,
|
||||
m4 = EXCLUDED.m4,
|
||||
m5 = EXCLUDED.m5,
|
||||
m6 = EXCLUDED.m6,
|
||||
updated_at = now()
|
||||
RETURNING 1
|
||||
),
|
||||
upsert_priceroll AS (
|
||||
INSERT INTO mk_priceroll (rule_id, currency, wholesale_step, retail_step, retail_mode, updated_at)
|
||||
SELECT rule_id, 'TRY', try_wholesale_step, try_retail_step, try_retail_mode, now() FROM resolved
|
||||
UNION ALL
|
||||
SELECT rule_id, 'USD', usd_wholesale_step, usd_retail_step, usd_retail_mode, now() FROM resolved
|
||||
UNION ALL
|
||||
SELECT rule_id, 'EUR', eur_wholesale_step, eur_retail_step, eur_retail_mode, now() FROM resolved
|
||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
wholesale_step = EXCLUDED.wholesale_step,
|
||||
retail_step = EXCLUDED.retail_step,
|
||||
retail_mode = EXCLUDED.retail_mode,
|
||||
updated_at = now()
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*)::int FROM resolved;
|
||||
`
|
||||
|
||||
var updated int
|
||||
if err := tx.QueryRowContext(ctx, q, raw).Scan(&updated); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func ListPricingRules(ctx context.Context, pg *sql.DB) ([]PricingRuleRow, error) {
|
||||
@@ -190,6 +409,11 @@ SELECT
|
||||
r.marka,
|
||||
r.brand_code,
|
||||
r.brand_group,
|
||||
r.strategy_code,
|
||||
r.anchor_mode,
|
||||
r.calc_enabled,
|
||||
r.publish_postgres,
|
||||
r.publish_nebim,
|
||||
r.is_active,
|
||||
|
||||
COALESCE(tx.base_mult, 0)::float8 AS try_base,
|
||||
@@ -201,6 +425,7 @@ SELECT
|
||||
COALESCE(tx.m6, 0)::float8 AS try6,
|
||||
COALESCE(NULLIF(tr.wholesale_step, 0), tr.step, 0)::float8 AS try_wholesale_step,
|
||||
COALESCE(NULLIF(tr.retail_step, 0), tr.step, 0)::float8 AS try_retail_step,
|
||||
COALESCE(NULLIF(BTRIM(tr.retail_mode), ''), 'STEP') AS try_retail_mode,
|
||||
|
||||
COALESCE(ux.base_mult, 0)::float8 AS usd_base,
|
||||
COALESCE(ux.m1, 0)::float8 AS usd1,
|
||||
@@ -211,6 +436,7 @@ SELECT
|
||||
COALESCE(ux.m6, 0)::float8 AS usd6,
|
||||
COALESCE(NULLIF(ur.wholesale_step, 0), ur.step, 0)::float8 AS usd_wholesale_step,
|
||||
COALESCE(NULLIF(ur.retail_step, 0), ur.step, 0)::float8 AS usd_retail_step,
|
||||
COALESCE(NULLIF(BTRIM(ur.retail_mode), ''), 'STEP') AS usd_retail_mode,
|
||||
|
||||
COALESCE(ex.base_mult, 0)::float8 AS eur_base,
|
||||
COALESCE(ex.m1, 0)::float8 AS eur1,
|
||||
@@ -220,7 +446,8 @@ SELECT
|
||||
COALESCE(ex.m5, 0)::float8 AS eur5,
|
||||
COALESCE(ex.m6, 0)::float8 AS eur6,
|
||||
COALESCE(NULLIF(er.wholesale_step, 0), er.step, 0)::float8 AS eur_wholesale_step,
|
||||
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8 AS eur_retail_step
|
||||
COALESCE(NULLIF(er.retail_step, 0), er.step, 0)::float8 AS eur_retail_step,
|
||||
COALESCE(NULLIF(BTRIM(er.retail_mode), ''), 'STEP') AS eur_retail_mode
|
||||
FROM mk_pricing_rule r
|
||||
LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY'
|
||||
LEFT JOIN mk_pricex ux ON ux.rule_id = r.id AND ux.currency='USD'
|
||||
@@ -252,14 +479,22 @@ ORDER BY r.created_at DESC;
|
||||
pq.Array(&r.Marka),
|
||||
pq.Array(&r.BrandCode),
|
||||
pq.Array(&r.BrandGroupSec),
|
||||
&r.StrategyCode,
|
||||
&r.AnchorMode,
|
||||
&r.CalcEnabled,
|
||||
&r.PublishPostgres,
|
||||
&r.PublishNebim,
|
||||
&r.IsActive,
|
||||
|
||||
&r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryWholesaleStep, &r.TryRetailStep,
|
||||
&r.UsdBase, &r.Usd1, &r.Usd2, &r.Usd3, &r.Usd4, &r.Usd5, &r.Usd6, &r.UsdWholesaleStep, &r.UsdRetailStep,
|
||||
&r.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurWholesaleStep, &r.EurRetailStep,
|
||||
&r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryWholesaleStep, &r.TryRetailStep, &r.TryRetailMode,
|
||||
&r.UsdBase, &r.Usd1, &r.Usd2, &r.Usd3, &r.Usd4, &r.Usd5, &r.Usd6, &r.UsdWholesaleStep, &r.UsdRetailStep, &r.UsdRetailMode,
|
||||
&r.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurWholesaleStep, &r.EurRetailStep, &r.EurRetailMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.TryRetailMode = normalizeRetailMode(r.TryRetailMode)
|
||||
r.UsdRetailMode = normalizeRetailMode(r.UsdRetailMode)
|
||||
r.EurRetailMode = normalizeRetailMode(r.EurRetailMode)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -282,6 +517,42 @@ func normalizeTextList(in []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func deriveStrategyCodeFromBrandGroup(values []string) string {
|
||||
for _, value := range values {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "CORE", "PREMIUM", "SARTORIAL":
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return "CORE"
|
||||
}
|
||||
|
||||
func deriveAnchorModeFromBrandGroup(ctx context.Context, tx *sql.Tx, values []string) string {
|
||||
for _, value := range values {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
var mode string
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT anchor_mode
|
||||
FROM mk_brandgrp
|
||||
WHERE UPPER(BTRIM(code)) = UPPER(BTRIM($1))
|
||||
OR UPPER(BTRIM(title)) = UPPER(BTRIM($1))
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`, normalized).Scan(&mode)
|
||||
if err == nil {
|
||||
mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||
if mode == "TRY" || mode == "USD" {
|
||||
return mode
|
||||
}
|
||||
}
|
||||
}
|
||||
return "USD"
|
||||
}
|
||||
|
||||
// UpsertPricingRule persists rule scope + per-currency multipliers/roundings.
|
||||
// Parameter-backed worksheet saves append a new rule version so older prices
|
||||
// remain queryable. Legacy rules without a parameter id keep update behavior.
|
||||
@@ -306,6 +577,11 @@ func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem
|
||||
item.Marka = normalizeTextList(item.Marka)
|
||||
item.BrandCode = normalizeTextList(item.BrandCode)
|
||||
item.BrandGroupSec = normalizeTextList(item.BrandGroupSec)
|
||||
item.StrategyCode = deriveStrategyCodeFromBrandGroup(item.BrandGroupSec)
|
||||
item.AnchorMode = deriveAnchorModeFromBrandGroup(ctx, tx, item.BrandGroupSec)
|
||||
item.TryRetailMode = normalizeRetailMode(item.TryRetailMode)
|
||||
item.UsdRetailMode = normalizeRetailMode(item.UsdRetailMode)
|
||||
item.EurRetailMode = normalizeRetailMode(item.EurRetailMode)
|
||||
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if item.PricingParameterID > 0 {
|
||||
@@ -317,12 +593,15 @@ func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem
|
||||
INSERT INTO mk_pricing_rule (
|
||||
pricing_parameter_id,
|
||||
askili_yan,kategori,urun_ilk_grubu,urun_ana_grubu,urun_alt_grubu,
|
||||
icerik,karisim,marka,brand_code,brand_group,is_active,created_at,updated_at
|
||||
icerik,karisim,marka,brand_code,brand_group,
|
||||
strategy_code,anchor_mode,calc_enabled,publish_postgres,publish_nebim,
|
||||
is_active,created_at,updated_at
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now())
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,now(),now())
|
||||
RETURNING id
|
||||
`, nullablePricingParameterID(item.PricingParameterID), pq.Array(item.AskiliYan), pq.Array(item.Kategori), pq.Array(item.UrunIlkGrubu), pq.Array(item.UrunAnaGrubu), pq.Array(item.UrunAltGrubu),
|
||||
pq.Array(item.Icerik), pq.Array(item.Karisim), pq.Array(item.Marka), pq.Array(item.BrandCode), pq.Array(item.BrandGroupSec),
|
||||
item.StrategyCode, item.AnchorMode, item.CalcEnabled, item.PublishPostgres, item.PublishNebim,
|
||||
item.IsActive,
|
||||
).Scan(&id); err != nil {
|
||||
return "", err
|
||||
@@ -341,13 +620,19 @@ UPDATE mk_pricing_rule SET
|
||||
marka=$10,
|
||||
brand_code=$11,
|
||||
brand_group=$12,
|
||||
is_active=$13,
|
||||
strategy_code=$13,
|
||||
anchor_mode=$14,
|
||||
calc_enabled=$15,
|
||||
publish_postgres=$16,
|
||||
publish_nebim=$17,
|
||||
is_active=$18,
|
||||
updated_at=now()
|
||||
WHERE id=$1
|
||||
`, id,
|
||||
nullablePricingParameterID(item.PricingParameterID),
|
||||
pq.Array(item.AskiliYan), pq.Array(item.Kategori), pq.Array(item.UrunIlkGrubu), pq.Array(item.UrunAnaGrubu), pq.Array(item.UrunAltGrubu),
|
||||
pq.Array(item.Icerik), pq.Array(item.Karisim), pq.Array(item.Marka), pq.Array(item.BrandCode), pq.Array(item.BrandGroupSec),
|
||||
item.StrategyCode, item.AnchorMode, item.CalcEnabled, item.PublishPostgres, item.PublishNebim,
|
||||
item.IsActive,
|
||||
); err != nil {
|
||||
return "", err
|
||||
@@ -371,41 +656,176 @@ ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
`, id, cur, base, m1, m2, m3, m4, m5, m6)
|
||||
return err
|
||||
}
|
||||
upsertRoll := func(cur string, wholesaleStep, retailStep float64) error {
|
||||
upsertRoll := func(cur string, wholesaleStep, retailStep float64, retailMode string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,now(),now())
|
||||
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, retail_mode, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,now(),now())
|
||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
step=EXCLUDED.step,
|
||||
wholesale_step=EXCLUDED.wholesale_step,
|
||||
retail_step=EXCLUDED.retail_step,
|
||||
retail_mode=EXCLUDED.retail_mode,
|
||||
updated_at=now()
|
||||
`, id, cur, wholesaleStep, wholesaleStep, retailStep)
|
||||
`, id, cur, wholesaleStep, wholesaleStep, retailStep, retailMode)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep); err != nil {
|
||||
if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep, item.TryRetailMode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep); err != nil {
|
||||
if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep, item.UsdRetailMode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep); err != nil {
|
||||
if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep, item.EurRetailMode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdatePricingRuleByIDFast updates an existing rule without parameter versioning/scope fill.
|
||||
// This is the fast path for worksheet saves where rule_id is already known.
|
||||
func UpdatePricingRuleByIDFast(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem) error {
|
||||
if tx == nil {
|
||||
return fmt.Errorf("nil tx")
|
||||
}
|
||||
ruleID := strings.TrimSpace(item.ID)
|
||||
if ruleID == "" {
|
||||
return fmt.Errorf("missing rule id")
|
||||
}
|
||||
|
||||
item.TryRetailMode = normalizeRetailMode(item.TryRetailMode)
|
||||
item.UsdRetailMode = normalizeRetailMode(item.UsdRetailMode)
|
||||
item.EurRetailMode = normalizeRetailMode(item.EurRetailMode)
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_pricing_rule SET
|
||||
calc_enabled=$2,
|
||||
publish_postgres=$3,
|
||||
publish_nebim=$4,
|
||||
is_active=$5,
|
||||
updated_at=now()
|
||||
WHERE id=$1
|
||||
`, ruleID, item.CalcEnabled, item.PublishPostgres, item.PublishNebim, item.IsActive); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
upsertX := func(cur string, base, m1, m2, m3, m4, m5, m6 float64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_pricex (rule_id, currency, base_mult, m1, m2, m3, m4, m5, m6, created_at, updated_at)
|
||||
VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,$7,$8,$9,now(),now())
|
||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
base_mult=EXCLUDED.base_mult,
|
||||
m1=EXCLUDED.m1,
|
||||
m2=EXCLUDED.m2,
|
||||
m3=EXCLUDED.m3,
|
||||
m4=EXCLUDED.m4,
|
||||
m5=EXCLUDED.m5,
|
||||
m6=EXCLUDED.m6,
|
||||
updated_at=now()
|
||||
`, ruleID, cur, base, m1, m2, m3, m4, m5, m6)
|
||||
return err
|
||||
}
|
||||
|
||||
upsertRoll := func(cur string, wholesaleStep, retailStep float64, retailMode string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_priceroll (rule_id, currency, step, wholesale_step, retail_step, retail_mode, created_at, updated_at)
|
||||
VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,now(),now())
|
||||
ON CONFLICT (rule_id, currency) DO UPDATE SET
|
||||
step=EXCLUDED.step,
|
||||
wholesale_step=EXCLUDED.wholesale_step,
|
||||
retail_step=EXCLUDED.retail_step,
|
||||
retail_mode=EXCLUDED.retail_mode,
|
||||
updated_at=now()
|
||||
`, ruleID, cur, wholesaleStep, wholesaleStep, retailStep, retailMode)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertRoll("TRY", item.TryWholesaleStep, item.TryRetailStep, item.TryRetailMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertRoll("USD", item.UsdWholesaleStep, item.UsdRetailStep, item.UsdRetailMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertRoll("EUR", item.EurWholesaleStep, item.EurRetailStep, item.EurRetailMode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertPricingRuleByParameterIDFast ensures there is a rule row for a pricing_parameter_id and
|
||||
// updates its multipliers/roundings in place. This avoids expensive parameter versioning and
|
||||
// scope fill during worksheet-style bulk saves.
|
||||
func UpsertPricingRuleByParameterIDFast(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem) (string, error) {
|
||||
if tx == nil {
|
||||
return "", fmt.Errorf("nil tx")
|
||||
}
|
||||
if item.PricingParameterID <= 0 {
|
||||
return "", fmt.Errorf("missing pricing_parameter_id")
|
||||
}
|
||||
|
||||
// Find latest rule for this parameter id (if any).
|
||||
var ruleID string
|
||||
_ = tx.QueryRowContext(ctx, `
|
||||
SELECT id::text
|
||||
FROM mk_pricing_rule
|
||||
WHERE pricing_parameter_id = $1
|
||||
ORDER BY created_at DESC, updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`, item.PricingParameterID).Scan(&ruleID)
|
||||
ruleID = strings.TrimSpace(ruleID)
|
||||
|
||||
if ruleID == "" {
|
||||
// Create minimal rule row; other fields have defaults and parameter scope is read from mk_urunpricingprmtr.
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO mk_pricing_rule (
|
||||
pricing_parameter_id,
|
||||
calc_enabled,
|
||||
publish_postgres,
|
||||
publish_nebim,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,now(),now())
|
||||
RETURNING id::text
|
||||
`, item.PricingParameterID, item.CalcEnabled, item.PublishPostgres, item.PublishNebim, item.IsActive).Scan(&ruleID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ruleID = strings.TrimSpace(ruleID)
|
||||
}
|
||||
if ruleID == "" {
|
||||
return "", fmt.Errorf("failed to resolve rule id")
|
||||
}
|
||||
|
||||
// Reuse the ID-fast updater now that we have an id.
|
||||
item.ID = ruleID
|
||||
if err := UpdatePricingRuleByIDFast(ctx, tx, item); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ruleID, nil
|
||||
}
|
||||
|
||||
func nullablePricingParameterID(id int64) any {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -115,6 +115,11 @@ func GetAllProductPricingRows(ctx context.Context, chunkSize int, filters Produc
|
||||
orderExpr = "rc.ProductCode"
|
||||
orderDir = "ASC"
|
||||
}
|
||||
orderBySQL := orderExpr + ` ` + orderDir
|
||||
if !strings.EqualFold(strings.TrimSpace(orderExpr), "rc.ProductCode") {
|
||||
orderBySQL += `,
|
||||
rc.ProductCode ASC`
|
||||
}
|
||||
|
||||
baseQuery := `
|
||||
IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes;
|
||||
@@ -230,8 +235,7 @@ func GetAllProductPricingRows(ctx context.Context, chunkSize int, filters Produc
|
||||
LEFT JOIN #disp_base db
|
||||
ON db.ItemCode = rc.ProductCode
|
||||
ORDER BY
|
||||
` + orderExpr + ` ` + orderDir + `,
|
||||
rc.ProductCode ASC;
|
||||
` + orderBySQL + `;
|
||||
`
|
||||
|
||||
rows, err := db.MssqlDB.QueryContext(ctx, baseQuery, args...)
|
||||
@@ -740,6 +744,11 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
|
||||
orderExpr = "rc.ProductCode"
|
||||
orderDir = "ASC"
|
||||
}
|
||||
orderBySQL := orderExpr + ` ` + orderDir
|
||||
if !strings.EqualFold(strings.TrimSpace(orderExpr), "rc.ProductCode") {
|
||||
orderBySQL += `,
|
||||
rc.ProductCode ASC`
|
||||
}
|
||||
productQuery := `
|
||||
IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes;
|
||||
IF OBJECT_ID('tempdb..#stock_base') IS NOT NULL DROP TABLE #stock_base;
|
||||
@@ -806,8 +815,7 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
|
||||
LEFT JOIN #stock_base sb
|
||||
ON sb.ItemCode = rc.ProductCode
|
||||
ORDER BY
|
||||
` + orderExpr + ` ` + orderDir + `,
|
||||
rc.ProductCode ASC
|
||||
` + orderBySQL + `
|
||||
OFFSET ` + strconv.Itoa(offset) + ` ROWS
|
||||
FETCH NEXT ` + strconv.Itoa(limit) + ` ROWS ONLY;
|
||||
`
|
||||
|
||||
25
svc/queries/product_pricing_dims_mssql.go
Normal file
25
svc/queries/product_pricing_dims_mssql.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package queries
|
||||
|
||||
// GetProductVariantDimsForPricing:
|
||||
// Pull variant dimension combos from Nebim stock tables (same source as product-stock-query UI).
|
||||
// We intentionally keep it small: only the keys we need to write dim-aware prices into PG sdprc.
|
||||
//
|
||||
// Note: Column semantics depend on your Nebim setup. We treat ItemDim1Code/ItemDim3Code as the
|
||||
// primary variant dimensions used by the e-commerce sdprc dim filters.
|
||||
const GetProductVariantDimsForPricing = `
|
||||
DECLARE @ProductCode NVARCHAR(50) = @p1;
|
||||
|
||||
SELECT DISTINCT
|
||||
LTRIM(RTRIM(ISNULL(S.ColorCode,''))) AS ColorCode,
|
||||
LTRIM(RTRIM(ISNULL(S.ItemDim1Code,''))) AS ItemDim1Code,
|
||||
LTRIM(RTRIM(ISNULL(S.ItemDim3Code,''))) AS ItemDim3Code
|
||||
FROM trStock S WITH(NOLOCK)
|
||||
WHERE S.ItemTypeCode = 1
|
||||
AND S.ItemCode = @ProductCode
|
||||
AND LEN(S.ItemCode) = 13
|
||||
AND LEN(@ProductCode) = 13
|
||||
ORDER BY
|
||||
LTRIM(RTRIM(ISNULL(S.ColorCode,''))),
|
||||
LTRIM(RTRIM(ISNULL(S.ItemDim1Code,''))),
|
||||
LTRIM(RTRIM(ISNULL(S.ItemDim3Code,'')));
|
||||
`
|
||||
362
svc/queries/product_pricing_fx_publish.go
Normal file
362
svc/queries/product_pricing_fx_publish.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"bssapp-backend/models"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FxDeltaPublishStats struct {
|
||||
RateDate string
|
||||
|
||||
Queued int
|
||||
Updated int // sdprc rows updated/inserted
|
||||
Skipped int // missing anchor or rule
|
||||
Failures int
|
||||
}
|
||||
|
||||
type sdprcPublishRow struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
Currency string `json:"currency"`
|
||||
LevelNo int `json:"level_no"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
func round2fx(v float64) float64 {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return 0
|
||||
}
|
||||
return math.Round(v*100) / 100
|
||||
}
|
||||
|
||||
func roundDerivedWithRule(rule *PricingRuleRow, currency string, level int, raw float64) float64 {
|
||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
||||
if level < 1 || level > 6 {
|
||||
return 0
|
||||
}
|
||||
if rule == nil {
|
||||
// Fallback: keep a stable 2-decimal behavior when no rule exists.
|
||||
return round2fx(raw)
|
||||
}
|
||||
|
||||
whStep := 0.0
|
||||
rtStep := 0.0
|
||||
rtMode := ""
|
||||
switch currency {
|
||||
case "TRY":
|
||||
whStep, rtStep, rtMode = rule.TryWholesaleStep, rule.TryRetailStep, rule.TryRetailMode
|
||||
case "USD":
|
||||
whStep, rtStep, rtMode = rule.UsdWholesaleStep, rule.UsdRetailStep, rule.UsdRetailMode
|
||||
case "EUR":
|
||||
whStep, rtStep, rtMode = rule.EurWholesaleStep, rule.EurRetailStep, rule.EurRetailMode
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
// In our model: level 1-5 = wholesale rounding, level 6 = retail rounding.
|
||||
if level >= 6 {
|
||||
return applyRetailRounding(raw, whStep, rtStep, rtMode)
|
||||
}
|
||||
return roundUpStep(raw, whStep)
|
||||
}
|
||||
|
||||
// PublishDerivedPricesFromAnchor recalculates derived currency tiers from the stored anchor tiers in sdprc.
|
||||
// Rule selection determines anchor_mode (USD/TRY). Anchor tiers are never modified here.
|
||||
func PublishDerivedPricesFromAnchor(ctx context.Context, pg *sql.DB, productCodes []string, rateDate string, forceFxRefresh bool) (int, int, error) {
|
||||
if len(productCodes) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
rateRow, err := resolvePricingFxRateByDate(ctx, pg, rateDate, forceFxRefresh, true)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if rateRow.UsdTry <= 0 || rateRow.EurTry <= 0 || rateRow.UsdEur <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid fx rates for date %s", rateRow.RateDate)
|
||||
}
|
||||
|
||||
// Load rule map once.
|
||||
ruleRows, err := ListPricingParameterRules(ctx, pg, PricingRuleOptionFilters{})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
rulesByScope := make(map[string]PricingParameterRuleRow, len(ruleRows))
|
||||
for _, item := range ruleRows {
|
||||
rulesByScope[item.ScopeKey] = item
|
||||
}
|
||||
|
||||
// Fetch product metadata (scope) from MSSQL.
|
||||
products, err := GetAllProductPricingRows(ctx, 1000, ProductPricingFilters{ProductCode: productCodes}, "productCode", false)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
byCode := make(map[string]models.ProductPricing, len(products))
|
||||
for _, p := range products {
|
||||
code := strings.TrimSpace(p.ProductCode)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
byCode[code] = p
|
||||
}
|
||||
|
||||
derivedTargets := make([]sdprcPublishRow, 0, len(productCodes)*12) // derived: 2 currencies * 6 levels
|
||||
skipped := 0
|
||||
|
||||
for _, codeRaw := range productCodes {
|
||||
code := strings.TrimSpace(codeRaw)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
product, ok := byCode[code]
|
||||
if !ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
scopeKey := pricingParameterScopeKey(pricingParameterRow{
|
||||
AskiliYan: strings.TrimSpace(product.AskiliYan),
|
||||
Kategori: strings.TrimSpace(product.Kategori),
|
||||
UrunIlkGrubu: strings.TrimSpace(product.UrunIlkGrubu),
|
||||
UrunAnaGrubu: strings.TrimSpace(product.UrunAnaGrubu),
|
||||
UrunAltGrubu: strings.TrimSpace(product.UrunAltGrubu),
|
||||
Icerik: strings.TrimSpace(product.Icerik),
|
||||
Marka: strings.TrimSpace(product.Marka),
|
||||
BrandCode: strings.TrimSpace(product.BrandCode),
|
||||
BrandGroupSec: strings.TrimSpace(product.BrandGroupSec),
|
||||
})
|
||||
ruleItem, ok := rulesByScope[scopeKey]
|
||||
if !ok || ruleItem.Rule == nil || !ruleItem.Rule.IsActive || !ruleItem.Rule.CalcEnabled {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
anchorMode := strings.ToUpper(strings.TrimSpace(ruleItem.Rule.AnchorMode))
|
||||
if anchorMode != "USD" && anchorMode != "TRY" {
|
||||
anchorMode = "USD"
|
||||
}
|
||||
|
||||
anchor, ok, err := loadLatestSdprcTiers(ctx, pg, code, anchorMode)
|
||||
if err != nil || !ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
switch anchorMode {
|
||||
case "USD":
|
||||
for i := 0; i < 6; i++ {
|
||||
level := i + 1
|
||||
usd := anchor[i]
|
||||
tryV := roundDerivedWithRule(ruleItem.Rule, "TRY", level, usd*rateRow.UsdTry)
|
||||
eurV := roundDerivedWithRule(ruleItem.Rule, "EUR", level, usd*rateRow.UsdEur)
|
||||
if tryV > 0 {
|
||||
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "TRY", LevelNo: level, Price: tryV})
|
||||
}
|
||||
if eurV > 0 {
|
||||
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "EUR", LevelNo: level, Price: eurV})
|
||||
}
|
||||
}
|
||||
default: // TRY
|
||||
for i := 0; i < 6; i++ {
|
||||
level := i + 1
|
||||
tryV := anchor[i]
|
||||
usd := roundDerivedWithRule(ruleItem.Rule, "USD", level, tryV/rateRow.UsdTry)
|
||||
eurV := roundDerivedWithRule(ruleItem.Rule, "EUR", level, tryV/rateRow.EurTry)
|
||||
if usd > 0 {
|
||||
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "USD", LevelNo: level, Price: usd})
|
||||
}
|
||||
if eurV > 0 {
|
||||
derivedTargets = append(derivedTargets, sdprcPublishRow{ProductCode: code, Currency: "EUR", LevelNo: level, Price: eurV})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(derivedTargets) == 0 {
|
||||
return 0, skipped, nil
|
||||
}
|
||||
written, err := bulkUpsertSdprcDerived(ctx, pg, derivedTargets)
|
||||
return written, skipped, err
|
||||
}
|
||||
|
||||
func loadLatestSdprcTiers(ctx context.Context, pg *sql.DB, productCode string, currency string) ([6]float64, bool, error) {
|
||||
var out [6]float64
|
||||
productCode = strings.TrimSpace(productCode)
|
||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
||||
if productCode == "" {
|
||||
return out, false, nil
|
||||
}
|
||||
if currency != "USD" && currency != "TRY" {
|
||||
return out, false, nil
|
||||
}
|
||||
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (sdprc.sdprcgrp_id)
|
||||
sdprc.sdprcgrp_id AS grp,
|
||||
COALESCE(sdprc.prc, 0)::float8 AS prc
|
||||
FROM sdprc
|
||||
JOIN mmitem ON mmitem.id = sdprc.mmitem_id
|
||||
WHERE mmitem.code = $1
|
||||
AND sdprc.crn = $2
|
||||
AND sdprc.sdprcgrp_id BETWEEN 1 AND 6
|
||||
AND sdprc.prc IS NOT NULL
|
||||
AND sdprc.prc > 0
|
||||
ORDER BY sdprc.sdprcgrp_id, sdprc.zlins_dttm DESC
|
||||
)
|
||||
SELECT grp, prc FROM latest ORDER BY grp;
|
||||
`, productCode, currency)
|
||||
if err != nil {
|
||||
return out, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
found := 0
|
||||
for rows.Next() {
|
||||
var grp int
|
||||
var prc float64
|
||||
if err := rows.Scan(&grp, &prc); err != nil {
|
||||
return out, false, err
|
||||
}
|
||||
if grp >= 1 && grp <= 6 && prc > 0 {
|
||||
out[grp-1] = prc
|
||||
found++
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return out, false, err
|
||||
}
|
||||
return out, found == 6, nil
|
||||
}
|
||||
|
||||
func bulkUpsertSdprcDerived(ctx context.Context, pg *sql.DB, targets []sdprcPublishRow) (int, error) {
|
||||
raw, err := json.Marshal(targets)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(2003, 1)`); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
q := `
|
||||
WITH input AS (
|
||||
SELECT *
|
||||
FROM jsonb_to_recordset($1::jsonb) AS x(product_code text, currency text, level_no int, price float8)
|
||||
),
|
||||
norm AS (
|
||||
SELECT
|
||||
NULLIF(BTRIM(product_code), '') AS product_code,
|
||||
UPPER(NULLIF(BTRIM(currency), '')) AS currency,
|
||||
COALESCE(level_no, 0) AS level_no,
|
||||
COALESCE(price, 0) AS price
|
||||
FROM input
|
||||
),
|
||||
dims_cache AS (
|
||||
SELECT
|
||||
NULLIF(BTRIM(c.product_code), '') AS product_code,
|
||||
c.dim1,
|
||||
c.dim3
|
||||
FROM mk_mmitem_dim_combo c
|
||||
JOIN norm
|
||||
ON norm.product_code = c.product_code
|
||||
WHERE c.dim1 IS NOT NULL
|
||||
),
|
||||
dims_sdprc AS (
|
||||
SELECT
|
||||
norm.product_code AS product_code,
|
||||
s.dim1 AS dim1,
|
||||
s.dim3 AS dim3
|
||||
FROM norm
|
||||
JOIN mmitem mm
|
||||
ON mm.code = norm.product_code
|
||||
JOIN sdprc s
|
||||
ON s.mmitem_id = mm.id
|
||||
WHERE s.dim1 IS NOT NULL
|
||||
AND s.dim1 > 0
|
||||
GROUP BY norm.product_code, s.dim1, s.dim3
|
||||
),
|
||||
dims AS (
|
||||
SELECT product_code, dim1, dim3 FROM dims_cache
|
||||
UNION
|
||||
SELECT product_code, dim1, dim3 FROM dims_sdprc
|
||||
),
|
||||
mapped AS (
|
||||
SELECT
|
||||
mm.id AS mmitem_id,
|
||||
m.sdprcgrp_id AS sdprcgrp_id,
|
||||
norm.currency AS crn,
|
||||
d.dim1 AS dim1,
|
||||
d.dim3 AS dim3,
|
||||
norm.price AS prc
|
||||
FROM norm
|
||||
JOIN dims d
|
||||
ON d.product_code = norm.product_code
|
||||
JOIN mk_price_target_map_pg m
|
||||
ON m.is_active = TRUE
|
||||
AND m.currency = norm.currency
|
||||
AND m.level_no = norm.level_no
|
||||
JOIN mmitem mm
|
||||
ON mm.code = norm.product_code
|
||||
WHERE norm.product_code IS NOT NULL
|
||||
AND norm.currency IN ('USD','EUR','TRY')
|
||||
AND norm.level_no BETWEEN 1 AND 6
|
||||
AND norm.price > 0
|
||||
AND m.sdprcgrp_id IS NOT NULL
|
||||
),
|
||||
latest AS (
|
||||
SELECT DISTINCT ON (s.mmitem_id, s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0))
|
||||
s.id,
|
||||
s.mmitem_id,
|
||||
s.sdprcgrp_id,
|
||||
s.crn,
|
||||
s.dim1,
|
||||
s.dim3
|
||||
FROM sdprc s
|
||||
JOIN mapped m
|
||||
ON m.mmitem_id = s.mmitem_id
|
||||
AND m.sdprcgrp_id = s.sdprcgrp_id
|
||||
AND m.crn = s.crn
|
||||
AND m.dim1 = s.dim1
|
||||
AND ((m.dim3 IS NULL AND s.dim3 IS NULL) OR (m.dim3 = s.dim3))
|
||||
ORDER BY s.mmitem_id, s.sdprcgrp_id, s.crn, s.dim1, COALESCE(s.dim3, 0), s.zlins_dttm DESC
|
||||
),
|
||||
updated AS (
|
||||
UPDATE sdprc s
|
||||
SET prc = m.prc,
|
||||
zlins_dttm = now()
|
||||
FROM latest l
|
||||
JOIN mapped m
|
||||
ON m.mmitem_id=l.mmitem_id AND m.sdprcgrp_id=l.sdprcgrp_id AND m.crn=l.crn
|
||||
AND m.dim1 = l.dim1 AND ((m.dim3 IS NULL AND l.dim3 IS NULL) OR (m.dim3 = l.dim3))
|
||||
WHERE s.id = l.id
|
||||
AND s.prc IS DISTINCT FROM m.prc
|
||||
RETURNING 1
|
||||
),
|
||||
inserted AS (
|
||||
INSERT INTO sdprc (mmitem_id, sdprcgrp_id, crn, dim1, dim3, prc, zlins_dttm)
|
||||
SELECT m.mmitem_id, m.sdprcgrp_id, m.crn, m.dim1, m.dim3, m.prc, now()
|
||||
FROM mapped m
|
||||
LEFT JOIN latest l
|
||||
ON l.mmitem_id=m.mmitem_id AND l.sdprcgrp_id=m.sdprcgrp_id AND l.crn=m.crn
|
||||
AND l.dim1 = m.dim1 AND ((l.dim3 IS NULL AND m.dim3 IS NULL) OR (l.dim3 = m.dim3))
|
||||
WHERE l.id IS NULL
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT (SELECT COUNT(*) FROM updated)::int + (SELECT COUNT(*) FROM inserted)::int;
|
||||
`
|
||||
var written int
|
||||
if err := tx.QueryRowContext(ctx, q, raw).Scan(&written); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
174
svc/queries/product_pricing_recalc_queue.go
Normal file
174
svc/queries/product_pricing_recalc_queue.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// EnqueuePriceRecalc enqueues product codes for delta FX publish.
|
||||
// It is safe to call repeatedly; duplicates in pending/processing are ignored.
|
||||
func EnqueuePriceRecalc(ctx context.Context, tx *sql.Tx, productCodes []string, reason string) (int, error) {
|
||||
if len(productCodes) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "manual"
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
inserted := 0
|
||||
for _, raw := range productCodes {
|
||||
code := strings.TrimSpace(raw)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[code]; ok {
|
||||
continue
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mk_price_recalc_queue (
|
||||
product_code, pricing_parameter_id, reason, status, attempts,
|
||||
available_at, queued_at, processed_at, last_error,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES ($1, NULL, $2, 'pending', 0, now(), now(), NULL, '', now(), now())
|
||||
`, code, reason)
|
||||
if err != nil {
|
||||
if pe, ok := err.(*pq.Error); ok && pe != nil && string(pe.Code) == "23505" {
|
||||
// Duplicate in pending/processing (partial unique index).
|
||||
continue
|
||||
}
|
||||
return inserted, err
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
type PriceRecalcQueueItem struct {
|
||||
ID int64
|
||||
ProductCode string
|
||||
Attempts int
|
||||
}
|
||||
|
||||
// ClaimPriceRecalcQueue claims up to limit pending items for processing (SKIP LOCKED).
|
||||
func ClaimPriceRecalcQueue(ctx context.Context, tx *sql.Tx, limit int) ([]PriceRecalcQueueItem, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
WITH picked AS (
|
||||
SELECT id
|
||||
FROM mk_price_recalc_queue
|
||||
WHERE status = 'pending'
|
||||
AND available_at <= now()
|
||||
ORDER BY queued_at
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE mk_price_recalc_queue q
|
||||
SET status = 'processing', updated_at = now()
|
||||
FROM picked
|
||||
WHERE q.id = picked.id
|
||||
RETURNING q.id, q.product_code, q.attempts;
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]PriceRecalcQueueItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var it PriceRecalcQueueItem
|
||||
if err := rows.Scan(&it.ID, &it.ProductCode, &it.Attempts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.ProductCode = strings.TrimSpace(it.ProductCode)
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func MarkPriceRecalcQueueDone(ctx context.Context, tx *sql.Tx, id int64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_price_recalc_queue
|
||||
SET status='done',
|
||||
processed_at = now(),
|
||||
updated_at = now(),
|
||||
last_error=''
|
||||
WHERE id=$1;
|
||||
`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func MarkPriceRecalcQueueFailed(ctx context.Context, tx *sql.Tx, id int64, attempts int, errText string) error {
|
||||
errText = strings.TrimSpace(errText)
|
||||
if len(errText) > 900 {
|
||||
errText = errText[:900]
|
||||
}
|
||||
// Exponential-ish backoff: 5m, 15m, 60m.
|
||||
delay := 5 * time.Minute
|
||||
if attempts >= 1 {
|
||||
delay = 15 * time.Minute
|
||||
}
|
||||
if attempts >= 2 {
|
||||
delay = 60 * time.Minute
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_price_recalc_queue
|
||||
SET status='failed',
|
||||
attempts = attempts + 1,
|
||||
processed_at = now(),
|
||||
updated_at = now(),
|
||||
last_error=$2,
|
||||
available_at = now() + $3::interval
|
||||
WHERE id=$1;
|
||||
`, id, errText, fmt.Sprintf("%d seconds", int(delay.Seconds())))
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkPriceRecalcQueueDoneByProductCodes marks pending/processing rows as done for given product codes.
|
||||
// This is useful when an immediate publish path completes successfully and we want to avoid a second run.
|
||||
func MarkPriceRecalcQueueDoneByProductCodes(ctx context.Context, tx *sql.Tx, productCodes []string) (int64, error) {
|
||||
if len(productCodes) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
clean := make([]string, 0, len(productCodes))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range productCodes {
|
||||
code := strings.TrimSpace(raw)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[code]; ok {
|
||||
continue
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
clean = append(clean, code)
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE mk_price_recalc_queue
|
||||
SET status='done',
|
||||
processed_at = now(),
|
||||
updated_at = now(),
|
||||
last_error=''
|
||||
WHERE product_code = ANY($1)
|
||||
AND status IN ('pending','processing');
|
||||
`, pq.Array(clean))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ra, _ := res.RowsAffected()
|
||||
return ra, nil
|
||||
}
|
||||
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