Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -3,12 +3,14 @@ package routes
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/internal/mailer"
|
||||
"bssapp-backend/models"
|
||||
"bssapp-backend/queries"
|
||||
"bssapp-backend/utils"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -1222,8 +1224,82 @@ func PostProductionProductCostingDefaultQuantitiesRefreshHandler(w http.Response
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "top_n": topN})
|
||||
}
|
||||
|
||||
// POST /api/pricing/production-product-costing/tbstok/exists-bulk
|
||||
// Validates whether given codes exist in URETIM dbo.tbStok (or match sModel rules).
|
||||
// Used by UI to highlight invalid codes before save.
|
||||
func PostProductionProductCostingTbStokExistsBulkHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
uretimDB := db.GetUretimDB()
|
||||
if uretimDB == nil {
|
||||
// Non-blocking UX helper: if URETIM isn't reachable in this environment, return empty result.
|
||||
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingTbStokExistsBulkResponse{
|
||||
Missing: []string{},
|
||||
Error: "URETIM baglantisi aktif degil",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
traceID := utils.TraceIDFromRequest(r)
|
||||
ctx := utils.ContextWithTraceID(r.Context(), traceID)
|
||||
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.tbstok.exists-bulk")
|
||||
|
||||
var req models.ProductionProductCostingTbStokExistsBulkRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// short timeout: this is a UX helper, must not hang (but should still complete on moderate load)
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// log a small sample to diagnose timeouts without flooding logs
|
||||
sample := make([]string, 0, 8)
|
||||
for _, c := range req.Codes {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
sample = append(sample, c)
|
||||
if len(sample) >= 8 {
|
||||
break
|
||||
}
|
||||
}
|
||||
logger.Info("lookup start", "codes", len(req.Codes), "sample", strings.Join(sample, ","))
|
||||
existsBy, err := queries.LookupTbStokExistsByCodes(checkCtx, uretimDB, req.Codes)
|
||||
if err != nil {
|
||||
logger.Warn("lookup failed", "err", err, "codes", len(req.Codes))
|
||||
// Non-blocking UX helper: return empty list + error so UI can continue without hard failure.
|
||||
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingTbStokExistsBulkResponse{
|
||||
Missing: []string{},
|
||||
Error: "tbStok sorgu hatasi",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
missing := make([]string, 0, 16)
|
||||
for code, ok := range existsBy {
|
||||
if !ok && strings.TrimSpace(code) != "" {
|
||||
missing = append(missing, code)
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingTbStokExistsBulkResponse{Missing: missing})
|
||||
}
|
||||
|
||||
// POST /api/pricing/production-product-costing/onml/save
|
||||
func PostProductionProductCostingOnMLSaveHandlerWithMailer(ml *mailer.GraphMailer) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
postProductionProductCostingOnMLSaveHandler(w, r, ml)
|
||||
}
|
||||
}
|
||||
|
||||
// Backward-compatible entrypoint (no mailer).
|
||||
func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.Request) {
|
||||
postProductionProductCostingOnMLSaveHandler(w, r, nil)
|
||||
}
|
||||
|
||||
func postProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.Request, ml *mailer.GraphMailer) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
uretimDB := db.GetUretimDB()
|
||||
@@ -1413,6 +1489,14 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
|
||||
logger.Warn("tx rollback failed", "trace_id", traceID, "n_onml_no", nOnMLNo, "err", err)
|
||||
}
|
||||
}()
|
||||
warnings := make([]string, 0, 4)
|
||||
// Determine whether this is a new costing record or an update (before header upsert).
|
||||
isUpdate := false
|
||||
{
|
||||
var flag int
|
||||
_ = tx.QueryRowContext(ctx, `SELECT CASE WHEN EXISTS (SELECT 1 FROM dbo.spUrtOnMLMas WITH (NOLOCK) WHERE nOnMLNo=@p1) THEN 1 ELSE 0 END`, nOnMLNo).Scan(&flag)
|
||||
isUpdate = flag == 1
|
||||
}
|
||||
|
||||
// Determine mamul turu inside same tx (to keep create atomic)
|
||||
mamulLabel := ""
|
||||
@@ -1512,6 +1596,96 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
|
||||
sKodu string
|
||||
}
|
||||
recipeQtyByKey := map[recipeKey]float64{}
|
||||
|
||||
// Bulk resolve stock type id from tbStok (huge performance win vs per-row queries).
|
||||
// IMPORTANT: Do NOT run tbStok lookups on the transaction connection.
|
||||
// We have seen network timeouts against the tbStok server poison the tx connection ("driver: bad connection"),
|
||||
// which then makes rollback/commit impossible and returns 500. Use a separate DB handle + short timeouts.
|
||||
lookupDB := mssqlDB
|
||||
if lookupDB == nil {
|
||||
lookupDB = uretimDB
|
||||
}
|
||||
uniqueCodes := make([]string, 0, len(req.Detail.Upserts))
|
||||
seenCode := map[string]struct{}{}
|
||||
for _, row := range req.Detail.Upserts {
|
||||
if row.NOnMLDetNo <= 0 {
|
||||
continue
|
||||
}
|
||||
code := strings.TrimSpace(row.SKodu)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenCode[code]; ok {
|
||||
continue
|
||||
}
|
||||
seenCode[code] = struct{}{}
|
||||
uniqueCodes = append(uniqueCodes, code)
|
||||
}
|
||||
stockTypeByCode := map[string]int{}
|
||||
bulkStockTypeLookupFailed := false
|
||||
if len(uniqueCodes) > 0 {
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Build a VALUES list with parameters: (VALUES (@p1), (@p2), ...)
|
||||
valParts := make([]string, 0, len(uniqueCodes))
|
||||
args := make([]any, 0, len(uniqueCodes))
|
||||
for i, code := range uniqueCodes {
|
||||
// Parameters are 1-based in our SQL style (@p1, @p2, ...)
|
||||
valParts = append(valParts, fmt.Sprintf("(@p%d)", i+1))
|
||||
args = append(args, code)
|
||||
}
|
||||
sqlText := fmt.Sprintf(`
|
||||
WITH C AS (
|
||||
SELECT LTRIM(RTRIM(V.code)) AS code
|
||||
FROM (VALUES %s) AS V(code)
|
||||
)
|
||||
SELECT
|
||||
C.code,
|
||||
ISNULL((
|
||||
SELECT TOP 1 ISNULL(CONVERT(int, ISNULL(S.nStokTipi, 0)), 0) AS nStokTipiID
|
||||
FROM dbo.tbStok S WITH (NOLOCK)
|
||||
WHERE ISNULL(S.IsBlocked, 0) = 0
|
||||
AND (
|
||||
REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(C.code, ' ', '')
|
||||
OR LTRIM(RTRIM(ISNULL(S.sModel,''))) = C.code
|
||||
OR C.code LIKE LTRIM(RTRIM(ISNULL(S.sModel,''))) + '%%'
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(C.code, ' ', '') THEN 0
|
||||
WHEN LTRIM(RTRIM(ISNULL(S.sModel,''))) = C.code THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
S.dteKayitTarihi DESC,
|
||||
S.nStokID DESC
|
||||
), 0) AS nStokTipiID
|
||||
FROM C
|
||||
`, strings.Join(valParts, ","))
|
||||
|
||||
rows, err := lookupDB.QueryContext(lookupCtx, sqlText, args...)
|
||||
if err != nil {
|
||||
// Do not fail the whole save for bulk lookup. We'll fallback to per-row queries below.
|
||||
logger.Error("bulk stok tipi lookup error (fallback to per-row)", "err", err)
|
||||
bulkStockTypeLookupFailed = true
|
||||
} else {
|
||||
for rows.Next() {
|
||||
var code string
|
||||
var nStokTipiID int
|
||||
if err := rows.Scan(&code, &nStokTipiID); err != nil {
|
||||
_ = rows.Close()
|
||||
logger.Error("bulk stok tipi scan error (fallback to per-row)", "err", err)
|
||||
bulkStockTypeLookupFailed = true
|
||||
break
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code != "" {
|
||||
stockTypeByCode[code] = nStokTipiID
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
}
|
||||
for _, row := range req.Detail.Upserts {
|
||||
if row.NOnMLDetNo <= 0 {
|
||||
skippedUpserts += 1
|
||||
@@ -1640,23 +1814,25 @@ WHERE nHammaddeTuruNo = @p1
|
||||
lTutar := unitTRY * qty
|
||||
lDovizTutari := unitUSD * qty
|
||||
|
||||
// Debug log for price resolution
|
||||
logger.Info("price debug",
|
||||
"s_kodu", strings.TrimSpace(row.SKodu),
|
||||
"qty", qty,
|
||||
"fiyat_girilen", row.FiyatGirilen,
|
||||
"fiyat_doviz", strings.TrimSpace(row.FiyatDoviz),
|
||||
"unitTRY", unitTRY,
|
||||
"lTutar", lTutar,
|
||||
"lDovizTutari", lDovizTutari,
|
||||
)
|
||||
// Keep logs lean: per-row price debug was too noisy and slow in large payloads.
|
||||
|
||||
// Resolve stock type id from tbStok by sKodu (exact), then fallback to model-based match.
|
||||
// Note: In this DB, stock type is stored as tbStok.nStokTipi but spUrtOnMLMasDet expects nStokTipiID (int).
|
||||
rawSKodu := strings.TrimSpace(row.SKodu)
|
||||
logger.Info("resolving stock type", "s_kodu", rawSKodu)
|
||||
var nStokTipiID int
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
nStokTipiID, ok := stockTypeByCode[rawSKodu]
|
||||
if !ok || nStokTipiID <= 0 {
|
||||
// If bulk lookup already failed (usually due to network/driver timeouts), do NOT attempt per-row lookups.
|
||||
// Per-row fallback would multiply latency and still likely fail, without adding value.
|
||||
if bulkStockTypeLookupFailed {
|
||||
nStokTipiID = 1
|
||||
if rawSKodu != "" {
|
||||
stockTypeByCode[rawSKodu] = 1
|
||||
}
|
||||
} else if rawSKodu != "" {
|
||||
// Fallback to per-row query. Cache results back into the map.
|
||||
var tmp int
|
||||
perRowCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
err := lookupDB.QueryRowContext(perRowCtx, `
|
||||
SELECT TOP 1 ISNULL(CONVERT(int, ISNULL(S.nStokTipi, 0)), 0) AS nStokTipiID
|
||||
FROM dbo.tbStok S WITH (NOLOCK)
|
||||
WHERE ISNULL(S.IsBlocked, 0) = 0
|
||||
@@ -1673,28 +1849,28 @@ ORDER BY
|
||||
END,
|
||||
S.dteKayitTarihi DESC,
|
||||
S.nStokID DESC
|
||||
`, rawSKodu).Scan(&nStokTipiID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// FALLBACK: If stock item not found in tbStok at all, default to 1.
|
||||
logger.Warn("stok tipi not found in tbStok, falling back to 1",
|
||||
"trace_id", traceID,
|
||||
"n_onml_no", nOnMLNo,
|
||||
"n_onml_det_no", row.NOnMLDetNo,
|
||||
"s_kodu", rawSKodu,
|
||||
)
|
||||
nStokTipiID = 1
|
||||
} else {
|
||||
logger.Error("stok tipi lookup error", "err", err)
|
||||
http.Error(w, "Stok tipi bulunamadi (tbStok sorgu hatasi)", http.StatusInternalServerError)
|
||||
return
|
||||
`, rawSKodu).Scan(&tmp)
|
||||
cancel()
|
||||
if err == nil {
|
||||
nStokTipiID = tmp
|
||||
stockTypeByCode[rawSKodu] = nStokTipiID
|
||||
} else if err == sql.ErrNoRows {
|
||||
// keep 0 -> will fallback to 1 below
|
||||
nStokTipiID = 0
|
||||
stockTypeByCode[rawSKodu] = 0
|
||||
} else {
|
||||
// Do not block save for stock type lookup failures.
|
||||
// Most common cause: tbStok DB is temporarily unreachable (timeouts / bad connection).
|
||||
logger.Error("stok tipi lookup error (per-row)", "err", err, "s_kodu", rawSKodu)
|
||||
nStokTipiID = 1
|
||||
stockTypeByCode[rawSKodu] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.Info("stock type resolved", "s_kodu", rawSKodu, "n_stok_tipi_id", nStokTipiID)
|
||||
if nStokTipiID <= 0 {
|
||||
// FALLBACK: If stock type is missing or 0 in tbStok, default to 1 (usually 'Raw Material' or 'General').
|
||||
// This prevents blocking the save process for items not fully configured in tbStok.
|
||||
logger.Warn("stok tipi <= 0, falling back to 1",
|
||||
logger.Warn("stok tipi <= 0 (bulk), falling back to 1",
|
||||
"trace_id", traceID,
|
||||
"n_onml_no", nOnMLNo,
|
||||
"n_onml_det_no", row.NOnMLDetNo,
|
||||
@@ -1886,8 +2062,9 @@ WHERE nUrtReceteID = @p1
|
||||
AND nUrtMBolumID = @p2
|
||||
AND LTRIM(RTRIM(ISNULL(nHStokID_G,''))) = @p3
|
||||
`, req.Header.NUrtReceteID, k.nUrtMBolumID, k.sKodu, q, user); err != nil {
|
||||
logger.Warn("recipe qty update failed", "trace_id", traceID, "n_urt_recete_id", req.Header.NUrtReceteID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
|
||||
continue
|
||||
logger.Error("recipe qty update failed", "trace_id", traceID, "n_urt_recete_id", req.Header.NUrtReceteID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
|
||||
http.Error(w, "Recete miktar guncellemesi basarisiz", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
updated++
|
||||
}
|
||||
@@ -1899,7 +2076,9 @@ WHERE nUrtReceteID = @p1
|
||||
SELECT ISNULL(MAX(CONVERT(int, nUrtRecMBolumID)), 0) AS MaxID
|
||||
FROM dbo.spUrtRecMBolum WITH (UPDLOCK, HOLDLOCK)
|
||||
`).Scan(&baseID); err != nil {
|
||||
logger.Warn("recipe base id lookup failed (skipping inserts)", "trace_id", traceID, "err", err)
|
||||
logger.Error("recipe base id lookup failed", "trace_id", traceID, "err", err)
|
||||
http.Error(w, "Recete insert hazirligi basarisiz", http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
inserted := 0
|
||||
nextID := baseID
|
||||
@@ -1913,8 +2092,9 @@ FROM dbo.spUrtRecMBolum WITH (UPDLOCK, HOLDLOCK)
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT CASE WHEN EXISTS (SELECT 1 FROM dbo.spUrtMBolum WITH (NOLOCK) WHERE nUrtMBolumID = @p1) THEN 1 ELSE 0 END
|
||||
`, k.nUrtMBolumID).Scan(&bolumExists); err != nil || bolumExists != 1 {
|
||||
logger.Warn("recipe insert skipped (missing spUrtMBolum FK)", "trace_id", traceID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu)
|
||||
continue
|
||||
logger.Error("recipe insert blocked (missing spUrtMBolum FK)", "trace_id", traceID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
|
||||
http.Error(w, "Recete insert engellendi (bolum FK yok)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
nextID++
|
||||
@@ -1960,8 +2140,9 @@ VALUES (
|
||||
@p7,GETDATE()
|
||||
)
|
||||
`, nextID, req.Header.NUrtReceteID, nUrtUBolumID, k.nUrtMBolumID, k.sKodu, q, user); err != nil {
|
||||
logger.Warn("recipe insert failed", "trace_id", traceID, "n_urt_recete_id", req.Header.NUrtReceteID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
|
||||
continue
|
||||
logger.Error("recipe insert failed", "trace_id", traceID, "n_urt_recete_id", req.Header.NUrtReceteID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
|
||||
http.Error(w, "Recete insert basarisiz", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
@@ -1978,18 +2159,227 @@ VALUES (
|
||||
committed = true
|
||||
logger.Info("tx commit ok", "trace_id", traceID, "n_onml_no", nOnMLNo)
|
||||
|
||||
// V3: update base price table so pricing screens reflect latest costing.
|
||||
// Not transactional with URETIM DB; if this fails, URETIM save has already succeeded.
|
||||
if mssqlDB != nil {
|
||||
logger.Info("post-commit step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "v3_base_price_upsert")
|
||||
if err := queries.UpsertV3ItemBasePriceUSD(ctx, mssqlDB, req.Header.UrunKodu, req.Header.MaliyetTarihi, totalUSD, user); err != nil {
|
||||
logger.Error("v3 base price upsert error", "err", err)
|
||||
http.Error(w, "URETIM kaydedildi ama V3 maliyet guncellenemedi", http.StatusInternalServerError)
|
||||
return
|
||||
// Post-commit async tasks (save latency reduction):
|
||||
// - V3 base price upsert (MSSQL)
|
||||
// - Costing mail send (Graph + Postgres mappings)
|
||||
// - Last10 avg deviation warnings (MSSQL cache -> Postgres panel)
|
||||
//
|
||||
// These must NOT block the HTTP response. They are retried with backoff and only logged on failure.
|
||||
{
|
||||
reqCopy := req
|
||||
if len(req.Detail.Upserts) > 0 {
|
||||
up := make([]models.ProductionProductCostingOnMLSaveDetailUpsertRow, len(req.Detail.Upserts))
|
||||
copy(up, req.Detail.Upserts)
|
||||
reqCopy.Detail.Upserts = up
|
||||
}
|
||||
if len(req.Detail.Deletes) > 0 {
|
||||
del := make([]models.ProductionProductCostingOnMLSaveDetailDeleteRow, len(req.Detail.Deletes))
|
||||
copy(del, req.Detail.Deletes)
|
||||
reqCopy.Detail.Deletes = del
|
||||
}
|
||||
|
||||
actorUser := user
|
||||
nOnMLNoLocal := nOnMLNo
|
||||
isUpdateLocal := isUpdate
|
||||
urunKoduLocal := strings.TrimSpace(req.Header.UrunKodu)
|
||||
maliyetTarihiLocal := strings.TrimSpace(req.Header.MaliyetTarihi)
|
||||
totalUSDLocal := totalUSD
|
||||
totalTRYLocal := totalTRY
|
||||
totalEURLocal := totalEUR
|
||||
usdRateLocal := usdRate
|
||||
eurRateLocal := eurRate
|
||||
gbpRateLocal := gbpRate
|
||||
mssqlLocal := mssqlDB
|
||||
uretimLocal := uretimDB
|
||||
pgLocal := db.PgDB
|
||||
mlLocal := ml
|
||||
traceIDLocal := traceID
|
||||
|
||||
go func() {
|
||||
bg := context.Background()
|
||||
bg = utils.ContextWithTraceID(bg, traceIDLocal)
|
||||
bgLogger := utils.SlogFromContext(bg).With("handler", "production-product-costing.onml.save.post-commit", "n_onml_no", nOnMLNoLocal)
|
||||
|
||||
// 1) V3 base price upsert: retry 3 times with backoff.
|
||||
if mssqlLocal != nil && urunKoduLocal != "" && maliyetTarihiLocal != "" {
|
||||
backoff := []time.Duration{300 * time.Millisecond, 1200 * time.Millisecond, 3500 * time.Millisecond}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < len(backoff)+1; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(backoff[attempt-1])
|
||||
}
|
||||
stepCtx, cancel := context.WithTimeout(bg, 10*time.Second)
|
||||
err := queries.UpsertV3ItemBasePriceUSD(stepCtx, mssqlLocal, urunKoduLocal, maliyetTarihiLocal, totalUSDLocal, actorUser)
|
||||
cancel()
|
||||
if err == nil {
|
||||
bgLogger.Info("post-commit ok", "step", "v3_base_price_upsert")
|
||||
lastErr = nil
|
||||
break
|
||||
}
|
||||
lastErr = err
|
||||
bgLogger.Warn("post-commit retry", "step", "v3_base_price_upsert", "attempt", attempt+1, "err", err)
|
||||
}
|
||||
if lastErr != nil {
|
||||
bgLogger.Error("post-commit failed", "step", "v3_base_price_upsert", "err", lastErr)
|
||||
}
|
||||
} else {
|
||||
bgLogger.Info("post-commit skipped", "step", "v3_base_price_upsert")
|
||||
}
|
||||
|
||||
// 2) Costing mail: retry 2 times with backoff.
|
||||
if mlLocal != nil && pgLocal != nil && mssqlLocal != nil {
|
||||
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, cancel := context.WithTimeout(bg, 25*time.Second)
|
||||
err := sendCostingSummaryMail(stepCtx, pgLocal, mssqlLocal, uretimLocal, mlLocal, reqCopy, nOnMLNoLocal, isUpdateLocal, usdRateLocal, eurRateLocal, gbpRateLocal, totalUSDLocal, totalTRYLocal, totalEURLocal, actorUser)
|
||||
cancel()
|
||||
if err == nil {
|
||||
bgLogger.Info("post-commit ok", "step", "costing_mail_send")
|
||||
lastErr = nil
|
||||
break
|
||||
}
|
||||
lastErr = err
|
||||
bgLogger.Warn("post-commit retry", "step", "costing_mail_send", "attempt", attempt+1, "err", err)
|
||||
}
|
||||
if lastErr != nil {
|
||||
bgLogger.Error("post-commit failed", "step", "costing_mail_send", "err", lastErr)
|
||||
}
|
||||
} else {
|
||||
bgLogger.Info("post-commit skipped", "step", "costing_mail_send")
|
||||
}
|
||||
|
||||
// 3) Last10 avg deviation warnings: dedupe by (code,currency), read from MSSQL cache, write to Postgres table.
|
||||
// Keep generous timeouts: this is async and should succeed on slow networks.
|
||||
if pgLocal != nil && mssqlLocal != nil && len(reqCopy.Detail.Upserts) > 0 {
|
||||
_, cancelBoot := context.WithTimeout(bg, 5*time.Second)
|
||||
if err := queries.EnsureProductionCostingLast10WarningTables(pgLocal); err != nil {
|
||||
cancelBoot()
|
||||
bgLogger.Error("post-commit failed", "step", "last10_warning_bootstrap", "err", err)
|
||||
return
|
||||
}
|
||||
cancelBoot()
|
||||
|
||||
// dedupe input by code+currency (USD basis comparison)
|
||||
inputByKey := map[string]float64{}
|
||||
inputUSDByKey := map[string]float64{}
|
||||
codes := make([]string, 0, len(reqCopy.Detail.Upserts))
|
||||
seenCode := map[string]struct{}{}
|
||||
|
||||
for _, row := range reqCopy.Detail.Upserts {
|
||||
code := strings.TrimSpace(row.SKodu)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
cur := strings.ToUpper(strings.TrimSpace(row.FiyatDoviz))
|
||||
if cur == "" {
|
||||
cur = "USD"
|
||||
}
|
||||
in := row.FiyatGirilen
|
||||
if in <= 0 {
|
||||
continue
|
||||
}
|
||||
key := code + "|" + cur
|
||||
if _, ok := inputByKey[key]; ok {
|
||||
continue
|
||||
}
|
||||
inputByKey[key] = in
|
||||
|
||||
// input USD basis
|
||||
inUSD := 0.0
|
||||
switch cur {
|
||||
case "USD":
|
||||
inUSD = in
|
||||
case "TRY", "TL":
|
||||
if usdRateLocal > 0 {
|
||||
inUSD = in / usdRateLocal
|
||||
}
|
||||
case "EUR":
|
||||
if usdRateLocal > 0 && eurRateLocal > 0 {
|
||||
inUSD = (in * eurRateLocal) / usdRateLocal
|
||||
}
|
||||
case "GBP":
|
||||
if usdRateLocal > 0 && gbpRateLocal > 0 {
|
||||
inUSD = (in * gbpRateLocal) / usdRateLocal
|
||||
}
|
||||
default:
|
||||
inUSD = in
|
||||
}
|
||||
inputUSDByKey[key] = inUSD
|
||||
|
||||
if _, ok := seenCode[code]; !ok {
|
||||
seenCode[code] = struct{}{}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
}
|
||||
|
||||
lookupCtx, cancelLookup := context.WithTimeout(bg, 6*time.Second)
|
||||
avgRows, err := queries.LookupLast10AvgPurchasePriceByItemCodes(lookupCtx, mssqlLocal, codes)
|
||||
cancelLookup()
|
||||
if err != nil {
|
||||
bgLogger.Warn("post-commit failed", "step", "last10_cache_lookup", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
avgUSDByKey := map[string]float64{}
|
||||
for _, ar := range avgRows {
|
||||
code := strings.TrimSpace(ar.ItemCode)
|
||||
cur := strings.ToUpper(strings.TrimSpace(ar.CurrencyCode))
|
||||
if code == "" || cur == "" || ar.SampleCount <= 0 || ar.AvgDocPrice <= 0 {
|
||||
continue
|
||||
}
|
||||
key := code + "|" + cur
|
||||
avgUSD := 0.0
|
||||
switch cur {
|
||||
case "USD":
|
||||
avgUSD = ar.AvgDocPrice
|
||||
case "TRY", "TL":
|
||||
if usdRateLocal > 0 {
|
||||
avgUSD = ar.AvgDocPrice / usdRateLocal
|
||||
}
|
||||
case "EUR":
|
||||
if usdRateLocal > 0 && eurRateLocal > 0 {
|
||||
avgUSD = (ar.AvgDocPrice * eurRateLocal) / usdRateLocal
|
||||
}
|
||||
case "GBP":
|
||||
if usdRateLocal > 0 && gbpRateLocal > 0 {
|
||||
avgUSD = (ar.AvgDocPrice * gbpRateLocal) / usdRateLocal
|
||||
}
|
||||
default:
|
||||
avgUSD = ar.AvgDocPrice
|
||||
}
|
||||
avgUSDByKey[key] = avgUSD
|
||||
}
|
||||
|
||||
writeCtx, cancelWrite := context.WithTimeout(bg, 12*time.Second)
|
||||
err = queries.ReplaceProductionCostingLast10Warnings(
|
||||
writeCtx,
|
||||
pgLocal,
|
||||
nOnMLNoLocal,
|
||||
urunKoduLocal,
|
||||
maliyetTarihiLocal,
|
||||
actorUser,
|
||||
avgRows,
|
||||
inputByKey,
|
||||
inputUSDByKey,
|
||||
avgUSDByKey,
|
||||
)
|
||||
cancelWrite()
|
||||
if err != nil {
|
||||
bgLogger.Error("post-commit failed", "step", "last10_warning_write", "err", err)
|
||||
return
|
||||
}
|
||||
bgLogger.Info("post-commit ok", "step", "last10_warning_write", "keys", len(inputByKey), "codes", len(codes), "cache_rows", len(avgRows))
|
||||
} else {
|
||||
bgLogger.Info("post-commit skipped", "step", "last10_warning_write")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingOnMLSaveResponse{NOnMLNo: nOnMLNo})
|
||||
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingOnMLSaveResponse{NOnMLNo: nOnMLNo, Warnings: warnings})
|
||||
}
|
||||
|
||||
// POST /api/pricing/production-product-costing/onml/delete
|
||||
@@ -2206,7 +2596,6 @@ func PostProductionHasCostDetailBulkPricesHandler(w http.ResponseWriter, r *http
|
||||
|
||||
costDate := strings.TrimSpace(req.MaliyetTarihi)
|
||||
itemsCount := len(req.Items)
|
||||
responseChan := make(chan *models.ProductionHasCostDetailBulkPriceRow, itemsCount)
|
||||
|
||||
logger.Info("request start",
|
||||
"n_onml_no", strings.TrimSpace(req.NOnMLNo),
|
||||
@@ -2216,38 +2605,112 @@ func PostProductionHasCostDetailBulkPricesHandler(w http.ResponseWriter, r *http
|
||||
)
|
||||
log.Printf("[ProductionHasCostDetailBulkPrices] start n_onml_no=%s urun_kodu=%s maliyet_tarihi=%s item_count=%d", strings.TrimSpace(req.NOnMLNo), strings.TrimSpace(req.UrunKodu), costDate, itemsCount)
|
||||
|
||||
for _, item := range req.Items {
|
||||
go func(item models.ProductionHasCostDetailPriceLookupItem) {
|
||||
sKodu := normalizeLookupValue(item.SKodu)
|
||||
if sKodu == "" {
|
||||
responseChan <- nil
|
||||
return
|
||||
// Bulk query (single roundtrip): send request items as JSON and resolve latest purchase price before cost date.
|
||||
type bulkReqItem struct {
|
||||
RowKey string `json:"rowKey"`
|
||||
SKodu string `json:"sKodu"`
|
||||
ColorCode string `json:"colorCode"`
|
||||
ItemDim1Code string `json:"itemDim1Code"`
|
||||
}
|
||||
reqItems := make([]bulkReqItem, 0, itemsCount)
|
||||
metaByRowKey := map[string]models.ProductionHasCostDetailPriceLookupItem{}
|
||||
for _, it := range req.Items {
|
||||
sKodu := normalizeLookupValue(it.SKodu)
|
||||
if sKodu == "" {
|
||||
continue
|
||||
}
|
||||
colorCode := firstNonEmptyString(
|
||||
normalizeLookupValue(it.ColorCode),
|
||||
normalizeLookupValue(it.SRenk),
|
||||
)
|
||||
itemDim1Code := firstNonEmptyString(normalizeLookupValue(it.ItemDim1Code))
|
||||
rowKey := strings.TrimSpace(it.RowKey)
|
||||
if rowKey == "" {
|
||||
// keep a stable key even if UI didn't pass it (should not happen).
|
||||
rowKey = strings.TrimSpace(it.NOnMLDetNo + "|" + sKodu)
|
||||
}
|
||||
reqItems = append(reqItems, bulkReqItem{RowKey: rowKey, SKodu: sKodu, ColorCode: colorCode, ItemDim1Code: itemDim1Code})
|
||||
metaByRowKey[rowKey] = it
|
||||
}
|
||||
|
||||
itemsJSONBytes, err := json.Marshal(reqItems)
|
||||
if err != nil {
|
||||
logger.Warn("bulk request invalid", "reason", "items json marshal failed", "err", err)
|
||||
http.Error(w, "Toplu fiyat verisi hazirlanamadi", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rows, err := queries.GetProductionHasCostLatestPurchasePricesForItems(ctx, mssqlDB, string(itemsJSONBytes), costDate)
|
||||
response := make([]models.ProductionHasCostDetailBulkPriceRow, 0, len(reqItems))
|
||||
if err != nil {
|
||||
// Fallback: some MSSQL instances are on low compatibility level and don't support OPENJSON.
|
||||
// In that case, fall back to the legacy per-item lookup but with bounded concurrency.
|
||||
logger.Warn("bulk lookup error (fallback to per-item)", "err", err)
|
||||
type job struct {
|
||||
rowKey string
|
||||
sKodu string
|
||||
colorCode string
|
||||
itemDim1Code string
|
||||
}
|
||||
jobs := make(chan job, len(reqItems))
|
||||
results := make(chan *models.ProductionHasCostDetailBulkPriceRow, len(reqItems))
|
||||
|
||||
worker := func() {
|
||||
for j := range jobs {
|
||||
row, qerr := queries.GetProductionHasCostLatestPurchasePriceForItem(ctx, mssqlDB, j.sKodu, j.colorCode, j.itemDim1Code, costDate)
|
||||
if qerr != nil {
|
||||
results <- nil
|
||||
continue
|
||||
}
|
||||
var res models.ProductionHasCostDetailBulkPriceRow
|
||||
if serr := row.Scan(
|
||||
&res.PriceType,
|
||||
&res.Tarih,
|
||||
&res.FaturaKodu,
|
||||
&res.MasrafKodu,
|
||||
&res.MasrafDetay,
|
||||
&res.ColorCode,
|
||||
&res.ColorDescription,
|
||||
&res.ItemDim1Code,
|
||||
&res.ItemDim1Description,
|
||||
&res.FiyatGirilen,
|
||||
&res.FiyatDoviz,
|
||||
); serr != nil {
|
||||
results <- nil
|
||||
continue
|
||||
}
|
||||
meta := metaByRowKey[j.rowKey]
|
||||
res.RowKey = strings.TrimSpace(meta.RowKey)
|
||||
if res.RowKey == "" {
|
||||
res.RowKey = j.rowKey
|
||||
}
|
||||
res.NOnMLDetNo = strings.TrimSpace(meta.NOnMLDetNo)
|
||||
res.NHammaddeTuruNo = strings.TrimSpace(meta.NHammaddeTuruNo)
|
||||
res.SKodu = normalizeLookupValue(meta.SKodu)
|
||||
results <- &res
|
||||
}
|
||||
}
|
||||
|
||||
colorCode := firstNonEmptyString(
|
||||
normalizeLookupValue(item.ColorCode),
|
||||
normalizeLookupValue(item.SRenk),
|
||||
)
|
||||
itemDim1Code := firstNonEmptyString(
|
||||
normalizeLookupValue(item.ItemDim1Code),
|
||||
)
|
||||
|
||||
row, err := queries.GetProductionHasCostLatestPurchasePriceForItem(
|
||||
ctx,
|
||||
mssqlDB,
|
||||
sKodu,
|
||||
colorCode,
|
||||
itemDim1Code,
|
||||
costDate,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn("item lookup error", "s_kodu", sKodu, "color_code", colorCode, "item_dim1_code", itemDim1Code, "err", err)
|
||||
responseChan <- nil
|
||||
return
|
||||
workerCount := 10
|
||||
for i := 0; i < workerCount; i++ {
|
||||
go worker()
|
||||
}
|
||||
for _, it := range reqItems {
|
||||
jobs <- job{rowKey: it.RowKey, sKodu: it.SKodu, colorCode: it.ColorCode, itemDim1Code: it.ItemDim1Code}
|
||||
}
|
||||
close(jobs)
|
||||
for i := 0; i < len(reqItems); i++ {
|
||||
r := <-results
|
||||
if r != nil {
|
||||
response = append(response, *r)
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var rowKey string
|
||||
var result models.ProductionHasCostDetailBulkPriceRow
|
||||
if err := row.Scan(
|
||||
if err := rows.Scan(
|
||||
&rowKey,
|
||||
&result.PriceType,
|
||||
&result.Tarih,
|
||||
&result.FaturaKodu,
|
||||
@@ -2260,32 +2723,21 @@ func PostProductionHasCostDetailBulkPricesHandler(w http.ResponseWriter, r *http
|
||||
&result.FiyatGirilen,
|
||||
&result.FiyatDoviz,
|
||||
); err != nil {
|
||||
logger.Warn("item scan error", "s_kodu", sKodu, "color_code", colorCode, "item_dim1_code", itemDim1Code, "err", err)
|
||||
responseChan <- nil
|
||||
return
|
||||
logger.Warn("bulk scan error", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
result.RowKey = strings.TrimSpace(item.RowKey)
|
||||
result.NOnMLDetNo = strings.TrimSpace(item.NOnMLDetNo)
|
||||
result.NHammaddeTuruNo = strings.TrimSpace(item.NHammaddeTuruNo)
|
||||
result.SKodu = sKodu
|
||||
|
||||
if strings.TrimSpace(result.ColorCode) == "" {
|
||||
result.ColorCode = colorCode
|
||||
meta := metaByRowKey[rowKey]
|
||||
result.RowKey = strings.TrimSpace(meta.RowKey)
|
||||
if result.RowKey == "" {
|
||||
result.RowKey = rowKey
|
||||
}
|
||||
if strings.TrimSpace(result.ItemDim1Code) == "" {
|
||||
result.ItemDim1Code = itemDim1Code
|
||||
}
|
||||
|
||||
responseChan <- &result
|
||||
}(item)
|
||||
}
|
||||
|
||||
response := make([]models.ProductionHasCostDetailBulkPriceRow, 0, itemsCount)
|
||||
for i := 0; i < itemsCount; i++ {
|
||||
res := <-responseChan
|
||||
if res != nil {
|
||||
response = append(response, *res)
|
||||
result.NOnMLDetNo = strings.TrimSpace(meta.NOnMLDetNo)
|
||||
result.NHammaddeTuruNo = strings.TrimSpace(meta.NHammaddeTuruNo)
|
||||
result.SKodu = normalizeLookupValue(meta.SKodu)
|
||||
response = append(response, result)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
logger.Warn("bulk rows error", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user