Merge remote-tracking branch 'origin/master'
This commit is contained in:
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