Files
bssapp/svc/routes/production_product_costing.go
2026-05-22 17:12:54 +03:00

3804 lines
123 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"strings"
"time"
)
func logProductionHasCostDetailEditorOptionItemDiagnostics(ctx context.Context, uretimDB *sql.DB, search string, limit int) {
logger := utils.SlogFromContext(ctx).With(
"handler", "production-product-costing.detail-editor-options.diagnostics",
"search", search,
"limit", limit,
)
expectedColumns := []string{
"nStokID",
"sKodu",
"sAciklama",
"sModel",
"sBirimCinsi1",
"IsBlocked",
}
probes := []struct {
name string
sqlText string
}{
{
name: "projection:nStokID",
sqlText: `SELECT TOP 1 RTRIM(CONVERT(VARCHAR(32), ISNULL(S.nStokID, 0))) FROM dbo.tbStok S`,
},
{
name: "projection:sKodu",
sqlText: `SELECT TOP 1 LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) FROM dbo.tbStok S`,
},
{
name: "projection:sAciklama",
sqlText: `SELECT TOP 1 LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sAciklama, '')))) FROM dbo.tbStok S`,
},
{
name: "projection:sModel",
sqlText: `SELECT TOP 1 LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) FROM dbo.tbStok S`,
},
{
name: "projection:sBirimCinsi1",
sqlText: `SELECT TOP 1 LTRIM(RTRIM(CONVERT(NVARCHAR(64), ISNULL(S.sBirimCinsi1, '')))) FROM dbo.tbStok S`,
},
{
name: "projection:IsBlocked",
sqlText: `SELECT TOP 1 RTRIM(CONVERT(VARCHAR(32), ISNULL(S.IsBlocked, 0))) FROM dbo.tbStok S`,
},
{
name: "filter:model_like_count",
sqlText: `SELECT RTRIM(CONVERT(VARCHAR(32), COUNT(1))) FROM dbo.tbStok S WHERE LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) LIKE '_.%'`,
},
{
name: "filter:isblocked_count",
sqlText: `SELECT RTRIM(CONVERT(VARCHAR(32), COUNT(1))) FROM dbo.tbStok S WHERE ISNULL(S.IsBlocked, 0) = 0`,
},
{
name: "filter:final_count",
sqlText: `SELECT RTRIM(CONVERT(VARCHAR(32), COUNT(1))) FROM dbo.tbStok S WHERE ISNULL(S.IsBlocked, 0) = 0 AND LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) LIKE '_.%'`,
},
}
log.Printf("[ProductionHasCostDetailEditorOptions] item diagnostics start search=%q limit=%d", search, limit)
for _, columnName := range expectedColumns {
var exists int
err := uretimDB.QueryRowContext(
ctx,
`SELECT COUNT(1)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'dbo'
AND TABLE_NAME = 'tbStok'
AND COLUMN_NAME = @p1`,
columnName,
).Scan(&exists)
if err != nil {
logger.Error("query error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] item diagnostics column check error column=%s err=%v", columnName, err)
continue
}
log.Printf("[ProductionHasCostDetailEditorOptions] item diagnostics column=%s exists=%t", columnName, exists > 0)
}
for _, probe := range probes {
var sample sql.NullString
err := uretimDB.QueryRowContext(ctx, probe.sqlText).Scan(&sample)
if err != nil {
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] item diagnostics probe=%s err=%v", probe.name, err)
continue
}
log.Printf("[ProductionHasCostDetailEditorOptions] item diagnostics probe=%s sample=%q", probe.name, strings.TrimSpace(sample.String))
}
log.Printf("[ProductionHasCostDetailEditorOptions] item diagnostics end search=%q limit=%d", search, limit)
}
// GET /api/pricing/production-product-costing/no-cost-products
func GetProductionNoCostProductsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
search := strings.TrimSpace(r.URL.Query().Get("search"))
fromDate := strings.TrimSpace(r.URL.Query().Get("from_date"))
rows, err := queries.GetProductionNoCostProducts(r.Context(), uretimDB, fromDate, search)
if err != nil {
log.Printf("❌ [ProductionNoCost] query error: %v", err)
http.Error(w, "Veritabanı hatası", http.StatusInternalServerError)
return
}
defer rows.Close()
list := make([]models.ProductionNoCostProductRow, 0, 200)
for rows.Next() {
var item models.ProductionNoCostProductRow
if err := rows.Scan(
&item.UretimSekli,
&item.UrtSiparisNo,
&item.IslemTarihi,
&item.FirmaKodu,
&item.FirmaAdi,
&item.SonIsEmriVeren,
&item.MMiktarG,
&item.MModelKodu,
&item.Kodu,
&item.ModelAdi,
&item.SKullaniciAdi,
&item.SKullaniciGunc,
); err != nil {
log.Printf("⚠️ [ProductionNoCost] scan error: %v", err)
continue
}
list = append(list, item)
}
if err := rows.Err(); err != nil {
log.Printf("⚠️ [ProductionNoCost] rows error: %v", err)
http.Error(w, "Veritabanı satır hatası", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(list)
}
// GET /api/pricing/production-product-costing/has-cost-products
func GetProductionHasCostProductsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
search := strings.TrimSpace(r.URL.Query().Get("search"))
offset := parsePositiveIntOrDefault(r.URL.Query().Get("offset"), 0)
limit := parsePositiveIntOrDefault(r.URL.Query().Get("limit"), 300)
if limit > 1000 {
limit = 1000
}
rows, err := queries.GetProductionHasCostProducts(r.Context(), uretimDB, search, offset, limit)
if err != nil {
log.Printf("❌ [ProductionHasCost] query error: %v", err)
http.Error(w, "Veritabanı hatası", http.StatusInternalServerError)
return
}
defer rows.Close()
list := make([]models.ProductionHasCostProductRow, 0, 200)
for rows.Next() {
var item models.ProductionHasCostProductRow
if err := rows.Scan(
&item.UretimSekli,
&item.NOnMLNo,
&item.UrunKodu,
&item.UrunAdi,
&item.Tarihi,
&item.DteKayitTarihi,
&item.SKullaniciAdi,
&item.LTutarTL,
&item.LTutarUSD,
&item.LTutarEURO,
&item.DteGuncellemeTarihi,
&item.SGuncellemeKullaniciAdi,
&item.NUrtReceteID,
&item.SAciklama,
&item.SonSiparisTarihi,
&item.MaliyetDurumu,
); err != nil {
log.Printf("⚠️ [ProductionHasCost] scan error: %v", err)
continue
}
list = append(list, item)
}
if err := rows.Err(); err != nil {
log.Printf("⚠️ [ProductionHasCost] rows error: %v", err)
http.Error(w, "Veritabanı satır hatası", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(list)
}
// GET /api/pricing/production-product-costing/has-cost-history
func GetProductionHasCostHistoryHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
productCode := strings.TrimSpace(r.URL.Query().Get("urun_kodu"))
if productCode == "" {
http.Error(w, "urun_kodu zorunlu", http.StatusBadRequest)
return
}
rows, err := queries.GetProductionHasCostHistoryByProductCode(r.Context(), uretimDB, productCode)
if err != nil {
log.Printf("❌ [ProductionHasCostHistory] query error: %v", err)
http.Error(w, "Veritabanı hatası", http.StatusInternalServerError)
return
}
defer rows.Close()
list := make([]models.ProductionHasCostHistoryRow, 0, 100)
for rows.Next() {
var item models.ProductionHasCostHistoryRow
if err := rows.Scan(
&item.NOnMLNo,
&item.UrunKodu,
&item.UrunAdi,
&item.Tarihi,
&item.SKullaniciAdi,
&item.LTutarUSD,
&item.LTutarTL,
&item.LTutarEURO,
&item.SDovizCinsi,
&item.LTutarDoviz,
&item.DteGuncellemeTarihi,
&item.SGuncellemeKullaniciAdi,
&item.NUrtReceteID,
&item.SAciklama,
); err != nil {
log.Printf("⚠️ [ProductionHasCostHistory] scan error: %v", err)
continue
}
list = append(list, item)
}
if err := rows.Err(); err != nil {
log.Printf("⚠️ [ProductionHasCostHistory] rows error: %v", err)
http.Error(w, "Veritabanı satır hatası", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(list)
}
// GET /api/pricing/production-product-costing/has-cost-detail-groups
func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
detailSource := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("detail_source")))
recipeCode := strings.TrimSpace(r.URL.Query().Get("recete_kodu"))
productCode := strings.TrimSpace(r.URL.Query().Get("urun_kodu"))
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With(
"handler", "production-product-costing.detail-groups",
"detail_source", detailSource,
"urun_kodu", productCode,
"recete_kodu", recipeCode,
)
if detailSource == "no-cost" || recipeCode != "" {
if recipeCode == "" {
logger.Warn("request invalid", "reason", "missing recete_kodu")
http.Error(w, "recete_kodu zorunlu", http.StatusBadRequest)
return
}
logger.Info("request start")
rows, err := queries.GetProductionNoCostDetailRowsByRecipeCode(ctx, uretimDB, recipeCode, productCode)
if err != nil {
logger.Error("query error", "err", err)
log.Printf("❌ [ProductionNoCostDetailGroups] query error: %v", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
groups := make([]models.ProductionHasCostDetailGroup, 0, 16)
groupIndexByName := map[string]int{}
scannedRows := 0
scanErrors := 0
for rows.Next() {
var (
groupName string
groupTotal float64
groupTotalUSD float64
nOnMLNoStr string
nOnMLDetNoStr string
hNoStr string
mtBolumStr string
fiyatGirilen sql.NullFloat64
fiyatDoviz sql.NullString
maliyeteDahil sql.NullBool
cmPriceTypeID sql.NullInt64
item models.ProductionHasCostDetailGroupItem
)
if err := rows.Scan(
&groupName,
&groupTotal,
&groupTotalUSD,
&nOnMLNoStr,
&nOnMLDetNoStr,
&hNoStr,
&mtBolumStr,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
&item.SBeden,
&item.SAciklama2,
&item.LMiktar,
&item.LFiyat,
&item.LTutar,
&item.SFiyatTipi,
&item.SDovizCinsi,
&item.LDovizKuru,
&item.LDovizFiyati,
&fiyatGirilen,
&fiyatDoviz,
&maliyeteDahil,
&cmPriceTypeID,
&item.USDTutar,
&item.EURTutar,
&item.GBPTutar,
&item.SBirim,
&item.SHammaddeTuruAdi,
&item.SParcaAdi,
); err != nil {
scanErrors += 1
logger.Warn("scan error", "scan_index", scannedRows+scanErrors+1, "err", err)
log.Printf("⚠️ [ProductionNoCostDetailGroups] scan error: %v", err)
continue
}
scannedRows += 1
// Model fields are strings; keep as-is to avoid type mismatches.
item.NOnMLNo = strings.TrimSpace(nOnMLNoStr)
item.NOnMLDetNo = strings.TrimSpace(nOnMLDetNoStr)
item.NHammaddeTuruNo = strings.TrimSpace(hNoStr)
item.NUrtMTBolumID = strings.TrimSpace(mtBolumStr)
if fiyatGirilen.Valid {
item.FiyatGirilen = new(float64)
*item.FiyatGirilen = fiyatGirilen.Float64
}
if fiyatDoviz.Valid {
item.FiyatDoviz = strings.TrimSpace(fiyatDoviz.String)
}
item.MaliyeteDahil = !maliyeteDahil.Valid || maliyeteDahil.Bool
if cmPriceTypeID.Valid {
value := int(cmPriceTypeID.Int64)
item.CMPriceTypeID = &value
}
idx, ok := groupIndexByName[groupName]
if !ok {
groups = append(groups, models.ProductionHasCostDetailGroup{
SAciklama3: groupName,
TotalTutar: groupTotal,
TotalUSDTutar: groupTotalUSD,
Items: make([]models.ProductionHasCostDetailGroupItem, 0, 8),
})
idx = len(groups) - 1
groupIndexByName[groupName] = idx
}
groups[idx].Items = append(groups[idx].Items, item)
}
if err := rows.Err(); err != nil {
logger.Error("rows error", "err", err)
log.Printf("⚠️ [ProductionNoCostDetailGroups] rows error: %v", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "group_count", len(groups), "row_count", scannedRows, "scan_errors", scanErrors)
log.Printf("[ProductionNoCostDetailGroups] done recete_kodu=%s groups=%d", recipeCode, len(groups))
_ = json.NewEncoder(w).Encode(groups)
return
}
rawOnMLNo := strings.TrimSpace(r.URL.Query().Get("n_onml_no"))
nOnMLNo, err := strconv.Atoi(rawOnMLNo)
if err != nil || nOnMLNo <= 0 {
logger.Warn("request invalid", "reason", "invalid n_onml_no", "raw_n_onml_no", rawOnMLNo)
http.Error(w, "n_onml_no zorunlu ve pozitif sayi olmali", http.StatusBadRequest)
return
}
logger = logger.With("n_onml_no", nOnMLNo)
logger.Info("request start")
log.Printf("[ProductionHasCostDetailGroups] start n_onml_no=%d", nOnMLNo)
rows, err := queries.GetProductionHasCostDetailRowsByOnMLNo(ctx, uretimDB, nOnMLNo)
if err != nil {
logger.Error("query error", "err", err)
log.Printf("❌ [ProductionHasCostDetailGroups] query error: %v", err)
http.Error(w, "Veritabanı hatası", http.StatusInternalServerError)
return
}
defer rows.Close()
groups := make([]models.ProductionHasCostDetailGroup, 0, 16)
groupIndexByName := map[string]int{}
scannedRows := 0
scanErrors := 0
for rows.Next() {
var (
groupName string
groupTotal float64
groupTotalUSD float64
nOnMLNoStr string
nOnMLDetNoStr string
hNoStr string
mtBolumStr string
fiyatGirilen sql.NullFloat64
fiyatDoviz sql.NullString
maliyeteDahil sql.NullBool
cmPriceTypeID sql.NullInt64
item models.ProductionHasCostDetailGroupItem
)
if err := rows.Scan(
&groupName,
&groupTotal,
&groupTotalUSD,
&nOnMLNoStr,
&nOnMLDetNoStr,
&hNoStr,
&mtBolumStr,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
&item.SBeden,
&item.SAciklama2,
&item.LMiktar,
&item.LFiyat,
&item.LTutar,
&item.SFiyatTipi,
&item.SDovizCinsi,
&item.LDovizKuru,
&item.LDovizFiyati,
&fiyatGirilen,
&fiyatDoviz,
&maliyeteDahil,
&cmPriceTypeID,
&item.USDTutar,
&item.EURTutar,
&item.GBPTutar,
&item.SBirim,
&item.SHammaddeTuruAdi,
&item.SParcaAdi,
); err != nil {
scanErrors++
logger.Warn("scan error", "scan_index", scannedRows+scanErrors, "err", err)
log.Printf("⚠️ [ProductionHasCostDetailGroups] scan error: %v", err)
continue
}
scannedRows++
// Model fields are strings; keep as-is to avoid type mismatches.
item.NOnMLNo = strings.TrimSpace(nOnMLNoStr)
item.NOnMLDetNo = strings.TrimSpace(nOnMLDetNoStr)
item.NHammaddeTuruNo = strings.TrimSpace(hNoStr)
item.NUrtMTBolumID = strings.TrimSpace(mtBolumStr)
if fiyatGirilen.Valid {
item.FiyatGirilen = new(float64)
*item.FiyatGirilen = fiyatGirilen.Float64
}
if fiyatDoviz.Valid {
item.FiyatDoviz = strings.TrimSpace(fiyatDoviz.String)
}
item.MaliyeteDahil = maliyeteDahil.Valid && maliyeteDahil.Bool
if cmPriceTypeID.Valid {
value := int(cmPriceTypeID.Int64)
item.CMPriceTypeID = &value
}
idx, ok := groupIndexByName[groupName]
if !ok {
groups = append(groups, models.ProductionHasCostDetailGroup{
SAciklama3: groupName,
TotalTutar: groupTotal,
TotalUSDTutar: groupTotalUSD,
Items: make([]models.ProductionHasCostDetailGroupItem, 0, 24),
})
idx = len(groups) - 1
groupIndexByName[groupName] = idx
}
groups[idx].Items = append(groups[idx].Items, item)
}
if err := rows.Err(); err != nil {
log.Printf("⚠️ [ProductionHasCostDetailGroups] rows error: %v", err)
http.Error(w, "Veritabanı satır hatası", http.StatusInternalServerError)
return
}
logger.Info("request done", "group_count", len(groups), "row_count", scannedRows, "scan_errors", scanErrors)
log.Printf("[ProductionHasCostDetailGroups] done n_onml_no=%d groups=%d rows=%d scan_errors=%d", nOnMLNo, len(groups), scannedRows, scanErrors)
_ = json.NewEncoder(w).Encode(groups)
}
// GET /api/pricing/production-product-costing/has-cost-detail-header
func GetProductionHasCostDetailHeaderHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
mssqlDB := db.GetDB()
detailSource := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("detail_source")))
recipeCode := strings.TrimSpace(r.URL.Query().Get("recete_kodu"))
productCode := strings.TrimSpace(r.URL.Query().Get("urun_kodu"))
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With(
"handler", "production-product-costing.detail-header",
"detail_source", detailSource,
"urun_kodu", productCode,
"recete_kodu", recipeCode,
)
if detailSource == "no-cost" || recipeCode != "" {
if recipeCode == "" {
logger.Warn("request invalid", "reason", "missing recete_kodu")
http.Error(w, "recete_kodu zorunlu", http.StatusBadRequest)
return
}
logger.Info("request start")
row, err := queries.GetProductionNoCostDetailHeaderByRecipeCode(ctx, uretimDB, recipeCode, productCode)
if err != nil {
logger.Error("query prepare error", "err", err)
log.Printf("❌ [ProductionNoCostDetailHeader] query prepare error: %v", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
var item models.ProductionHasCostDetailHeader
if err := row.Scan(
&item.UretimiYapanFirma,
&item.SonIsEmriVeren,
&item.FirmaKodu,
&item.NFirmaID,
&item.NOnMLNo,
&item.UrunKodu,
&item.UrunAdi,
&item.UretimSekliID,
&item.UretimSekli,
&item.DteKayitTarihi,
&item.SKullaniciAdi,
&item.LTutarTL,
&item.LTutarUSD,
&item.LTutarEURO,
&item.LTutarGBP,
&item.SDovizCinsi,
&item.LTutarDoviz,
&item.DteGuncellemeTarihi,
&item.SGuncellemeKullaniciAdi,
&item.NUrtReceteID,
); err != nil {
logger.Warn("scan or not found", "err", err)
if err == sql.ErrNoRows {
logger.Warn("row not found")
http.Error(w, "Kayit bulunamadi", http.StatusNotFound)
return
}
log.Printf("❌ [ProductionNoCostDetailHeader] scan error: %v", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "n_urt_recete_id", item.NUrtReceteID, "urun_kodu", item.UrunKodu)
if mssqlDB != nil {
ilk, ana, alt, err := queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssqlDB, item.UrunKodu)
if err != nil {
logger.Warn("product group query error", "err", err)
} else {
item.UrunIlkGrubu = ilk
item.UrunAnaGrubu = ana
item.UrunAltGrubu = alt
}
}
_ = json.NewEncoder(w).Encode(item)
return
}
rawOnMLNo := strings.TrimSpace(r.URL.Query().Get("n_onml_no"))
nOnMLNo, err := strconv.Atoi(rawOnMLNo)
if err != nil || nOnMLNo <= 0 {
logger.Warn("request invalid", "reason", "invalid n_onml_no", "raw_n_onml_no", rawOnMLNo)
http.Error(w, "n_onml_no zorunlu ve pozitif sayi olmali", http.StatusBadRequest)
return
}
logger = logger.With("n_onml_no", nOnMLNo)
logger.Info("request start")
row, err := queries.GetProductionHasCostDetailHeaderByOnMLNo(ctx, uretimDB, nOnMLNo)
if err != nil {
logger.Error("query prepare error", "err", err)
log.Printf("❌ [ProductionHasCostDetailHeader] query prepare error: %v", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
var item models.ProductionHasCostDetailHeader
if err := row.Scan(
&item.UretimiYapanFirma,
&item.SonIsEmriVeren,
&item.FirmaKodu,
&item.NFirmaID,
&item.NOnMLNo,
&item.UrunKodu,
&item.UrunAdi,
&item.UretimSekliID,
&item.UretimSekli,
&item.MaliyetTarihi,
&item.DteKayitTarihi,
&item.SKullaniciAdi,
&item.LTutarTL,
&item.LTutarUSD,
&item.LTutarEURO,
&item.LTutarGBP,
&item.SDovizCinsi,
&item.LTutarDoviz,
&item.DteGuncellemeTarihi,
&item.SGuncellemeKullaniciAdi,
&item.NUrtReceteID,
); err != nil {
logger.Warn("scan or not found", "err", err)
if err == sql.ErrNoRows {
http.Error(w, "Kayit bulunamadi", http.StatusNotFound)
return
}
log.Printf("❌ [ProductionHasCostDetailHeader] scan error: %v", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "n_onml_no", item.NOnMLNo, "urun_kodu", item.UrunKodu, "n_urt_recete_id", item.NUrtReceteID)
if mssqlDB != nil {
ilk, ana, alt, err := queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssqlDB, item.UrunKodu)
if err != nil {
logger.Warn("product group query error", "err", err)
} else {
item.UrunIlkGrubu = ilk
item.UrunAnaGrubu = ana
item.UrunAltGrubu = alt
}
}
_ = json.NewEncoder(w).Encode(item)
}
// GET /api/pricing/production-product-costing/production-types
func GetProductionTypesHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.production-types")
logger.Info("request start")
rows, err := queries.GetProductionTypes(ctx, uretimDB)
if err != nil {
logger.Error("query error", "err", err)
log.Printf("❌ [ProductionTypes] query error: %v", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
var types []models.ProductionType
for rows.Next() {
var t models.ProductionType
if err := rows.Scan(&t.ID, &t.Aciklama); err != nil {
logger.Warn("scan error", "err", err)
log.Printf("⚠️ [ProductionTypes] scan error: %v", err)
continue
}
types = append(types, t)
}
if err := rows.Err(); err != nil {
logger.Error("rows error", "err", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "row_count", len(types))
_ = json.NewEncoder(w).Encode(types)
}
// GET /api/pricing/production-product-costing/detail-editor-options
func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
kind := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("kind")))
search := strings.TrimSpace(r.URL.Query().Get("search"))
limit := parsePositiveIntOrDefault(r.URL.Query().Get("limit"), 100)
if limit > 200 {
limit = 200
}
logger := utils.SlogFromContext(ctx).With(
"handler", "production-product-costing.detail-editor-options",
"kind", kind,
"search", search,
"limit", limit,
)
logger.Info("request start")
switch kind {
case "hammadde":
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
group := strings.TrimSpace(r.URL.Query().Get("group"))
rawOnlyActive := strings.TrimSpace(r.URL.Query().Get("only_active"))
var onlyActive *bool = nil
if rawOnlyActive != "" {
v := rawOnlyActive == "1" || strings.EqualFold(rawOnlyActive, "true")
onlyActive = &v
}
rows, err := queries.GetProductionHasCostDetailHammaddeTypeOptions(ctx, uretimDB, search, limit, group, onlyActive)
if err != nil {
logger.Error("hammadde query error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] hammadde query error: %v", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
if columns, columnErr := rows.Columns(); columnErr != nil {
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] item columns read error search=%q limit=%d err=%v", search, limit, columnErr)
} else {
log.Printf("[ProductionHasCostDetailEditorOptions] item columns=%v", columns)
}
list := make([]models.ProductionHasCostDetailEditorOption, 0, limit)
for rows.Next() {
var item models.ProductionHasCostDetailEditorOption
if err := rows.Scan(&item.NHammaddeTuruNo, &item.SHammaddeTuruAdi, &item.SAciklama3, &item.MTUrtMTBolumID, &item.SParcaAdi); err != nil {
logger.Warn("hammadde scan error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] hammadde scan error: %v", err)
continue
}
item.Kind = "hammadde"
item.Value = item.NHammaddeTuruNo
item.Label = strings.TrimSpace(item.NHammaddeTuruNo + " - " + item.SHammaddeTuruAdi)
list = append(list, item)
}
if err := rows.Err(); err != nil {
logger.Error("hammadde rows error", "err", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "kind", "hammadde", "row_count", len(list))
_ = json.NewEncoder(w).Encode(list)
return
case "item":
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
log.Printf("[ProductionHasCostDetailEditorOptions] item start search=%q limit=%d", search, limit)
rows, err := queries.GetProductionHasCostDetailItemOptions(ctx, uretimDB, search, limit)
if err != nil {
logger.Error("item query error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] item query error search=%q limit=%d err=%v", search, limit, err)
logProductionHasCostDetailEditorOptionItemDiagnostics(ctx, uretimDB, search, limit)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
if columns, columnErr := rows.Columns(); columnErr != nil {
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] item columns read error search=%q limit=%d err=%v", search, limit, columnErr)
} else {
log.Printf("[ProductionHasCostDetailEditorOptions] item columns=%v", columns)
}
list := make([]models.ProductionHasCostDetailEditorOption, 0, limit)
scanErrors := 0
for rows.Next() {
var item models.ProductionHasCostDetailEditorOption
if err := rows.Scan(&item.NStokID, &item.SKodu, &item.SAciklama, &item.SModel, &item.SBirim); err != nil {
scanErrors += 1
logger.Warn("item scan error", "scan_index", len(list)+scanErrors, "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] item scan error scan_index=%d err=%v", len(list)+scanErrors, err)
continue
}
item.Kind = "item"
// Use variantless code (tbStok.sModel) as the value for costing/recipe.
// Variant-coded tbStok.sKodu can include color/variant suffixes; costing uses base model codes.
code := strings.TrimSpace(item.SModel)
if code == "" {
code = strings.TrimSpace(item.SKodu)
}
item.Value = code
item.Label = strings.TrimSpace(code + " - " + item.SAciklama)
list = append(list, item)
}
if err := rows.Err(); err != nil {
logger.Error("item rows error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] item rows error search=%q limit=%d err=%v", search, limit, err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "kind", "item", "row_count", len(list), "scan_errors", scanErrors)
log.Printf("[ProductionHasCostDetailEditorOptions] item done search=%q limit=%d row_count=%d scan_errors=%d", search, limit, len(list), scanErrors)
_ = json.NewEncoder(w).Encode(list)
return
case "color":
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
modelCode := strings.TrimSpace(r.URL.Query().Get("model_code"))
if modelCode == "" {
_ = json.NewEncoder(w).Encode([]models.ProductionHasCostDetailEditorOption{})
return
}
rows, err := queries.GetProductionHasCostDetailColorOptions(ctx, uretimDB, modelCode, search, limit)
if err != nil {
logger.Error("color query error", "model_code", modelCode, "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] color query error: %v", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
list := make([]models.ProductionHasCostDetailEditorOption, 0, limit)
for rows.Next() {
var item models.ProductionHasCostDetailEditorOption
if err := rows.Scan(&item.ColorCode, &item.ColorDescription); err != nil {
logger.Warn("color scan error", "model_code", modelCode, "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] color scan error: %v", err)
continue
}
item.Kind = "color"
item.Value = item.ColorCode
item.Label = strings.TrimSpace(item.ColorCode + " - " + item.ColorDescription)
list = append(list, item)
}
if err := rows.Err(); err != nil {
logger.Error("color rows error", "model_code", modelCode, "err", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "kind", "color", "model_code", modelCode, "row_count", len(list))
_ = json.NewEncoder(w).Encode(list)
return
}
logger.Warn("request invalid", "reason", "invalid kind")
http.Error(w, "kind hammadde, item veya color olmali", http.StatusBadRequest)
}
// GET /api/pricing/production-product-costing/default-quantities
func GetProductionProductCostingDefaultQuantitiesHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
search := strings.TrimSpace(r.URL.Query().Get("search"))
limit := parsePositiveIntOrDefault(r.URL.Query().Get("limit"), 500)
// Always list only active hammadde turleri.
rows, err := queries.ListProductionProductCostingDefaultQtyRows(ctx, uretimDB, search, limit)
if err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
out := make([]models.ProductionProductCostingDefaultQtyRow, 0, 256)
for rows.Next() {
var item models.ProductionProductCostingDefaultQtyRow
if err := rows.Scan(&item.NHammaddeTuruNo, &item.SAciklama, &item.LDefaultMiktar, &item.DteCalcTarihi, &item.BAktif); err != nil {
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(out)
}
// POST /api/pricing/production-product-costing/default-quantities/upsert
func PostProductionProductCostingDefaultQuantitiesUpsertHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
var req models.ProductionProductCostingDefaultQtyUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
if req.NHammaddeTuruNo <= 0 {
http.Error(w, "nHammaddeTuruNo zorunlu", http.StatusBadRequest)
return
}
if req.LDefaultMiktar <= 0 {
http.Error(w, "lDefaultMiktar pozitif olmali", http.StatusBadRequest)
return
}
if err := queries.UpsertProductionProductCostingDefaultQtyRow(ctx, uretimDB, req.NHammaddeTuruNo, req.LDefaultMiktar, req.BAktif); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/pricing/production-product-costing/default-quantities/update-bulk
func PostProductionProductCostingDefaultQuantitiesBulkUpdateHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
var req models.ProductionProductCostingDefaultQtyBulkUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
if len(req.Items) == 0 {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "updated": 0})
return
}
if len(req.Items) > 5000 {
http.Error(w, "cok fazla satir", http.StatusBadRequest)
return
}
tx, err := uretimDB.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer func() { _ = tx.Rollback() }()
updated := 0
for _, item := range req.Items {
if item.NHammaddeTuruNo <= 0 {
http.Error(w, "nHammaddeTuruNo zorunlu", http.StatusBadRequest)
return
}
if item.LDefaultMiktar <= 0 {
http.Error(w, "lDefaultMiktar pozitif olmali", http.StatusBadRequest)
return
}
activeVal := -1
if item.BAktif != nil {
if *item.BAktif {
activeVal = 1
} else {
activeVal = 0
}
}
sqlText := `
UPDATE dbo.mk_MaliyetParcaEslestirme_vmiktarlar
SET
lDefaultMiktar = @p2,
dteCalcTarihi = GETDATE(),
bAktif = CASE WHEN @p3 < 0 THEN ISNULL(bAktif, 1) ELSE @p3 END
WHERE nHammaddeTuruNo = @p1;
SELECT @@ROWCOUNT;
`
var affected int
if err := tx.QueryRowContext(ctx, sqlText, item.NHammaddeTuruNo, item.LDefaultMiktar, activeVal).Scan(&affected); err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
if affected == 0 {
http.Error(w, "satir bulunamadi: "+strconv.Itoa(item.NHammaddeTuruNo), http.StatusBadRequest)
return
}
updated++
}
if err := tx.Commit(); err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "updated": updated})
}
// POST /api/pricing/production-product-costing/default-quantities/calc-avg
func PostProductionProductCostingDefaultQuantitiesCalcAvgHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
var req models.ProductionProductCostingDefaultQtyCalcRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
if req.NHammaddeTuruNo <= 0 {
http.Error(w, "nHammaddeTuruNo zorunlu", http.StatusBadRequest)
return
}
if req.TopN <= 0 {
req.TopN = 10
}
avg, cnt, err := queries.CalcProductionProductCostingDefaultQtyFromLastOnML(ctx, uretimDB, req.NHammaddeTuruNo, req.TopN)
if err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingDefaultQtyCalcResponse{
NHammaddeTuruNo: req.NHammaddeTuruNo,
LDefaultMiktar: avg,
NSampleCount: cnt,
})
}
// POST /api/pricing/production-product-costing/default-quantities/lookup
func PostProductionProductCostingDefaultQuantitiesLookupHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
var req models.ProductionProductCostingDefaultQtyLookupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
rows, err := queries.LookupProductionProductCostingDefaultQtyByNos(ctx, uretimDB, req.NHammaddeTuruNos)
if err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
out := make([]models.ProductionProductCostingDefaultQtyLookupItem, 0, len(rows))
for _, r0 := range rows {
out = append(out, models.ProductionProductCostingDefaultQtyLookupItem{
NHammaddeTuruNo: r0.No,
SAciklama: r0.Aciklama,
LDefaultMiktar: r0.Qty,
})
}
_ = json.NewEncoder(w).Encode(out)
}
// POST /api/pricing/production-product-costing/has-cost-detail/last-detail
func PostProductionProductCostingHasCostDetailLastDetailHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
var req models.ProductionProductCostingLastOnMLDetLookupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
rows, err := queries.LookupLastOnMLMasDetByHammaddeNos(ctx, uretimDB, req.NHammaddeTuruNos, req.BeforeDate, req.ExcludeOnMLNo, req.NFirmaID, req.OnlyICode, 1)
if err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(rows)
}
// POST /api/pricing/production-product-costing/options/hammadde-by-nos
func PostProductionProductCostingOptionsHammaddeByNosHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
var req models.ProductionProductCostingHammaddeByNosRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
rows, err := queries.GetProductionHammaddeByNos(ctx, uretimDB, req.NHammaddeTuruNos)
if err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(rows)
}
// POST /api/pricing/production-product-costing/default-quantities/refresh
func PostProductionProductCostingDefaultQuantitiesRefreshHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
topN := parsePositiveIntOrDefault(r.URL.Query().Get("top_n"), 10)
if err := queries.RefreshProductionProductCostingDefaultQty(ctx, uretimDB, topN); err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_ = 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()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
mssqlDB := db.GetDB()
claims, _ := auth.GetClaimsFromContext(r.Context())
user := ""
if claims != nil {
user = strings.TrimSpace(claims.Username)
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.onml.save")
var req models.ProductionProductCostingOnMLSaveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logger.Warn("invalid json", "err", err)
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
// Guardrail: do not allow blank stock codes for rows that specify a hammadde type.
// UI should already block this, but we enforce it server-side too.
for _, row := range req.Detail.Upserts {
if row.NHammaddeTuruNo > 0 && strings.TrimSpace(row.SKodu) == "" {
logger.Warn("validation failed: blank s_kodu", "n_hammadde_turu_no", row.NHammaddeTuruNo, "n_onml_det_no", row.NOnMLDetNo)
http.Error(w, "Kod bos olamaz (hammadde turu secili satir var)", http.StatusBadRequest)
return
}
}
// Header validation: uretim sekli must be selected.
if req.Header.UretimSekliID <= 0 {
logger.Warn("validation failed: uretim_sekli_id <= 0", "uretim_sekli_id", req.Header.UretimSekliID)
http.Error(w, "Uretim sekli secilmeden kayit yapilamaz", http.StatusBadRequest)
return
}
req.DetailSource = strings.ToLower(strings.TrimSpace(req.DetailSource))
req.Header.UrunKodu = strings.TrimSpace(req.Header.UrunKodu)
req.Header.UrunAdi = strings.TrimSpace(req.Header.UrunAdi)
req.Header.MaliyetTarihi = strings.TrimSpace(req.Header.MaliyetTarihi)
req.Header.SAciklama = strings.TrimSpace(req.Header.SAciklama)
req.Header.FirmaKodu = strings.TrimSpace(req.Header.FirmaKodu)
if req.Header.UrunKodu == "" || req.Header.MaliyetTarihi == "" {
logger.Warn("validation failed: missing required header fields",
"trace_id", traceID,
"urun_kodu", req.Header.UrunKodu,
"maliyet_tarihi", req.Header.MaliyetTarihi,
)
http.Error(w, "urun_kodu ve maliyet_tarihi zorunlu", http.StatusBadRequest)
return
}
// Resolve exchange rates (for price conversions).
usdRate := 0.0
eurRate := 0.0
gbpRate := 0.0
if mssqlDB != nil {
row, err := queries.GetProductionHasCostDetailExchangeRatesByDate(ctx, mssqlDB, req.Header.MaliyetTarihi)
if err != nil {
logger.Error("exchange rate query error", "err", err)
http.Error(w, "Kur bilgisi alinamadi", http.StatusInternalServerError)
return
}
var rateDate string
if err := row.Scan(&rateDate, &usdRate, &eurRate, &gbpRate); err != nil {
logger.Error("exchange rate scan error", "err", err)
http.Error(w, "Kur bilgisi alinamadi", http.StatusInternalServerError)
return
}
}
if usdRate <= 0 {
usdRate = 1
}
if eurRate <= 0 {
eurRate = 1
}
if gbpRate <= 0 {
gbpRate = 1
}
// Resolve firma id
firmaID := req.Header.NFirmaID
if firmaID <= 0 && req.Header.FirmaKodu != "" {
id, err := queries.LookupFirmaIDByKodu(ctx, uretimDB, req.Header.FirmaKodu)
if err != nil {
logger.Error("firma lookup error", "err", err)
http.Error(w, "Firma bulunamadi", http.StatusBadRequest)
return
}
firmaID = id
}
if firmaID <= 0 {
logger.Warn("validation failed: missing firma",
"trace_id", traceID,
"n_firma_id", req.Header.NFirmaID,
"firma_kodu", req.Header.FirmaKodu,
)
http.Error(w, "Firma secilmeden kaydedilemez (n_firma_id / firma_kodu)", http.StatusBadRequest)
return
}
// Resolve or generate nOnMLNo
nOnMLNo := req.Header.NOnMLNo
if nOnMLNo <= 0 {
next, err := queries.GetNextOnMLNoFrom100k(ctx, uretimDB)
if err != nil {
logger.Error("next onmlno error", "err", err)
http.Error(w, "Yeni nOnMLNo uretilemedi", http.StatusInternalServerError)
return
}
nOnMLNo = next
}
// Resolve / create nMamulTuruNo from product groups (UrunAna + UrunAlt)
nMamulTuruNo := 1
// Compute totals from incoming rows (TRY/USD/EUR)
totalTRY := 0.0
totalUSD := 0.0
totalEUR := 0.0
for _, r := range req.Detail.Upserts {
qty := r.LMiktar
if qty < 0 {
qty = 0
}
// Convert input price to TRY (unit)
cur := strings.ToUpper(strings.TrimSpace(r.FiyatDoviz))
in := r.FiyatGirilen
unitTRY := in
switch cur {
case "USD":
unitTRY = in * usdRate
case "EUR":
unitTRY = in * eurRate
case "GBP":
unitTRY = in * gbpRate
case "TRY", "TL", "":
unitTRY = in
default:
unitTRY = in
}
unitUSD := unitTRY / usdRate
unitEUR := unitTRY / eurRate
totalTRY += unitTRY * qty
totalUSD += unitUSD * qty
totalEUR += unitEUR * qty
}
// Parse Tarihi
tarihi, err := time.Parse("2006-01-02", req.Header.MaliyetTarihi)
if err != nil {
logger.Warn("validation failed: maliyet_tarihi parse error",
"trace_id", traceID,
"maliyet_tarihi", req.Header.MaliyetTarihi,
"err", err,
)
http.Error(w, "maliyet_tarihi YYYY-MM-DD formatinda olmali", http.StatusBadRequest)
return
}
tx, err := uretimDB.BeginTx(ctx, nil)
if err != nil {
logger.Error("tx begin error", "err", err)
http.Error(w, "Islem baslatilamadi", http.StatusInternalServerError)
return
}
committed := false
logger.Info("tx begin",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"urun_kodu", req.Header.UrunKodu,
"maliyet_tarihi", req.Header.MaliyetTarihi,
)
defer func() {
if committed {
return
}
if err := tx.Rollback(); err == nil {
logger.Info("tx rollback", "trace_id", traceID, "n_onml_no", nOnMLNo)
} else {
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 := ""
if mssqlDB != nil {
_, ana, alt, err := queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssqlDB, req.Header.UrunKodu)
if err == nil {
ana = strings.TrimSpace(ana)
alt = strings.TrimSpace(alt)
if alt == "" || alt == "-" {
mamulLabel = ana
} else {
// matches inserted convention: "ANA-ALT" (no spaces)
mamulLabel = strings.TrimSpace(ana + "-" + alt)
}
}
}
if strings.TrimSpace(mamulLabel) != "" && mamulLabel != "-" {
mt, err := queries.GetOnMLMamulTuruNoByAciklama(ctx, tx, mamulLabel)
if err != nil {
logger.Error("mamul turu resolve error", "err", err)
http.Error(w, "Mamul turu olusturulamadi", http.StatusInternalServerError)
return
}
if mt <= 0 {
logger.Warn("validation failed: mamul turu not found", "trace_id", traceID, "n_onml_no", nOnMLNo, "mamul_label", mamulLabel)
http.Error(w, "Mamul turu bulunamadi: "+mamulLabel, http.StatusBadRequest)
return
}
nMamulTuruNo = mt
} else {
nMamulTuruNo = 1
}
var receteID sql.NullInt64
if req.Header.NUrtReceteID > 0 {
receteID = sql.NullInt64{Int64: int64(req.Header.NUrtReceteID), Valid: true}
}
var uretimSekliID sql.NullInt64
if req.Header.UretimSekliID > 0 {
uretimSekliID = sql.NullInt64{Int64: int64(req.Header.UretimSekliID), Valid: true}
}
var sAciklama sql.NullString
if req.Header.SAciklama != "" {
sAciklama = sql.NullString{String: req.Header.SAciklama, Valid: true}
}
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "upsert_header")
if err := queries.UpsertOnMLHeader(tx, ctx, queries.OnMLHeaderUpsertArgs{
NOnMLNo: nOnMLNo,
UrunKodu: req.Header.UrunKodu,
UrunAdi: req.Header.UrunAdi,
Tarihi: tarihi,
NMamulTuruNo: nMamulTuruNo,
NUrtReceteID: receteID,
UretimSekliID: uretimSekliID,
SAciklama: sAciklama,
NFirmaID: firmaID,
SUser: user,
LTutarTL: totalTRY,
LTutarUSD: totalUSD,
LTutarEURO: totalEUR,
SDovizCinsi: "USD",
LTutarDoviz: totalUSD,
}); err != nil {
logger.Error("header upsert error", "err", err)
http.Error(w, "Header kaydedilemedi", http.StatusInternalServerError)
return
}
// Deletes
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "detail_deletes", "count", len(req.Detail.Deletes))
skippedDeletes := 0
for _, d := range req.Detail.Deletes {
if d.NOnMLDetNo <= 0 {
skippedDeletes += 1
continue
}
if _, err := tx.ExecContext(ctx, `DELETE FROM dbo.spUrtOnMLMasDet WHERE nOnMLNo=@p1 AND nOnMLDetNo=@p2`, nOnMLNo, d.NOnMLDetNo); err != nil {
logger.Error("detail delete error", "err", err)
http.Error(w, "Detay silinemedi", http.StatusInternalServerError)
return
}
}
if skippedDeletes > 0 {
logger.Warn("detail deletes skipped (det_no<=0)", "trace_id", traceID, "n_onml_no", nOnMLNo, "skipped", skippedDeletes)
}
// Upserts
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "detail_upserts", "count", len(req.Detail.Upserts))
skippedUpserts := 0
skippedUpsertsSample := 0
// Cache hammadde_turu -> mt_bolum_id so we don't query master table for every row.
mtBolumByHammadde := map[int]int{}
// Collect source rows for recipe sync (variantless, non-CM2 only).
type recipeKey struct {
nUrtMBolumID int
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
if skippedUpsertsSample < 5 {
skippedUpsertsSample += 1
logger.Warn("detail upsert skipped (det_no<=0)",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"n_hammadde_turu_no", row.NHammaddeTuruNo,
"s_kodu", strings.TrimSpace(row.SKodu),
"s_aciklama3", strings.TrimSpace(row.SAciklama3),
)
}
continue
}
if row.NHammaddeTuruNo <= 0 || strings.TrimSpace(row.SKodu) == "" {
// FALLBACK: If nHammaddeTuruNo is missing but sKodu is present, default to 1 (General/Labor)
// to avoid blocking the user, especially for labor items.
if strings.TrimSpace(row.SKodu) != "" && row.NHammaddeTuruNo <= 0 {
logger.Warn("n_hammadde_turu_no <= 0, falling back to 1",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"s_kodu", strings.TrimSpace(row.SKodu),
)
row.NHammaddeTuruNo = 1
} else {
logger.Warn("validation failed: missing required detail fields (s_kodu empty)",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"n_hammadde_turu_no", row.NHammaddeTuruNo,
"s_kodu", strings.TrimSpace(row.SKodu),
)
http.Error(w, "Detay satirinda s_kodu zorunlu", http.StatusBadRequest)
return
}
}
// Guard: keep part/section binding stable.
// UI sometimes doesn't send n_urt_mt_bolum_id; if we write 0 into spUrtOnMLMasDet,
// joins to spUrtMTBolum will break and "parca adi" will render as "-".
if row.NUrtMTBolumID <= 0 && row.NHammaddeTuruNo > 0 {
if cached, ok := mtBolumByHammadde[row.NHammaddeTuruNo]; ok {
if cached > 0 {
row.NUrtMTBolumID = cached
}
} else {
var mtID int
err := tx.QueryRowContext(ctx, `
SELECT TOP 1 ISNULL(MTnUrtMTBolumID, 0) AS MTnUrtMTBolumID
FROM dbo.spUrtOnMLHammaddeTuru WITH (NOLOCK)
WHERE nHammaddeTuruNo = @p1
`, row.NHammaddeTuruNo).Scan(&mtID)
if err != nil && err != sql.ErrNoRows {
logger.Warn("mt bolum lookup error (will keep incoming value)",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"n_hammadde_turu_no", row.NHammaddeTuruNo,
"err", err,
)
mtBolumByHammadde[row.NHammaddeTuruNo] = 0
} else {
mtBolumByHammadde[row.NHammaddeTuruNo] = mtID
if mtID > 0 {
row.NUrtMTBolumID = mtID
logger.Info("mt bolum auto-filled from hammadde master",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"n_hammadde_turu_no", row.NHammaddeTuruNo,
"n_urt_mt_bolum_id", mtID,
)
} else {
logger.Warn("mt bolum missing on hammadde master (keeping 0)",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"n_hammadde_turu_no", row.NHammaddeTuruNo,
)
}
}
}
}
qty := row.LMiktar
if qty < 0 {
qty = 0
}
// Build recipe sync source data:
// - never include CM2 / labor groups
// - never include empty codes
// - use variantless code (we already normalize sKodu on read; here we trust request)
if req.Header.NUrtReceteID > 0 {
group := strings.ToUpper(strings.TrimSpace(row.SAciklama3))
code := strings.TrimSpace(row.SKodu)
if code != "" && group != "CM2" && !strings.Contains(strings.ToUpper(code), " CM2") {
if row.NHammaddeTuruNo > 0 {
k := recipeKey{nUrtMBolumID: row.NHammaddeTuruNo, sKodu: code}
recipeQtyByKey[k] += qty
}
}
}
cur := strings.ToUpper(strings.TrimSpace(row.FiyatDoviz))
in := row.FiyatGirilen
unitTRY := in
switch cur {
case "USD":
unitTRY = in * usdRate
case "EUR":
unitTRY = in * eurRate
case "GBP":
unitTRY = in * gbpRate
case "TRY", "TL", "":
unitTRY = in
default:
unitTRY = in
}
unitUSD := unitTRY / usdRate
lTutar := unitTRY * qty
lDovizTutari := unitUSD * qty
// 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)
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
AND (
REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(@p1, ' ', '')
OR LTRIM(RTRIM(ISNULL(S.sModel,''))) = @p1
OR @p1 LIKE LTRIM(RTRIM(ISNULL(S.sModel,''))) + '%'
)
ORDER BY
CASE
WHEN REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(@p1, ' ', '') THEN 0
WHEN LTRIM(RTRIM(ISNULL(S.sModel,''))) = @p1 THEN 1
ELSE 2
END,
S.dteKayitTarihi DESC,
S.nStokID DESC
`, 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
}
}
}
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 (bulk), falling back to 1",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"s_kodu", rawSKodu,
"n_stok_tipi_id_orig", nStokTipiID,
)
nStokTipiID = 1
}
// Dummy/system-mapped required fields:
const bOtoFiyat = 0
const lFireOrani = 0
const nTutarGirisTipiNo = 1
const sFiyatTipi = "ML"
const nKurTipiNo = 6
const lEkMasraf = 0
const lMiktar2 = 0
const lEkFiyat = 0
// MERGE by (nOnMLNo, nOnMLDetNo)
mergeSQL := `
MERGE dbo.spUrtOnMLMasDet AS T
USING (SELECT @p1 AS nOnMLNo, @p2 AS nOnMLDetNo) AS S
ON (T.nOnMLNo = S.nOnMLNo AND T.nOnMLDetNo = S.nOnMLDetNo)
WHEN MATCHED THEN
UPDATE SET
nHammaddeTuruNo = @p3,
sKodu = @p4,
sAciklama = @p5,
sRenk = @p6,
sBeden = NULLIF(@p7,''),
sAciklama2 = NULLIF(@p8,''),
lMiktar = @p9,
lFiyat = @p10,
lTutar = @p11,
bOtoFiyat = @p12,
lFireOrani = @p13,
nTutarGirisTipiNo = @p14,
sFiyatTipi = @p15,
sDovizCinsi = @p16,
nKurTipiNo = @p17,
lDovizKuru = @p18,
lDovizFiyati = @p19,
sBirim = @p20,
lDovizTutari = @p21,
lEkMasraf = @p22,
lMiktar2 = @p23,
nStokTipiID = @p24,
lEkFiyat = @p25,
nUrtMTBolumID = @p26,
fiyat_girilen = NULLIF(@p27, 0),
fiyat_doviz = NULLIF(@p28,''),
Maliyete_dahil = @p29,
cm_price_type_id = @p30,
sKullaniciAdiDeg = @p31,
dteIslemTarihiDeg = GETDATE(),
sAciklama3 = NULLIF(@p32, '')
WHEN NOT MATCHED THEN
INSERT (
nOnMLNo,nOnMLDetNo,nHammaddeTuruNo,sKodu,sAciklama,sRenk,lMiktar,lFiyat,lTutar,
bOtoFiyat,lFireOrani,nTutarGirisTipiNo,sFiyatTipi,sDovizCinsi,nKurTipiNo,lDovizKuru,lDovizFiyati,
sBirim,sAciklama2,lDovizTutari,lEkMasraf,sKullaniciAdi,dteIslemTarihi,sBeden,sAciklama3,lMiktar2,nStokTipiID,
lEkFiyat,nUrtMTBolumID,fiyat_girilen,fiyat_doviz,Maliyete_dahil,cm_price_type_id
)
VALUES (
@p1,@p2,@p3,@p4,@p5,@p6,@p9,@p10,@p11,
@p12,@p13,@p14,@p15,@p16,@p17,@p18,@p19,
@p20,NULLIF(@p8,''),@p21,@p22,@p31,GETDATE(),NULLIF(@p7,''),NULLIF(@p32, ''),@p23,@p24,
@p25,@p26,NULLIF(@p27,0),NULLIF(@p28,''),@p29,@p30
);
`
if _, err := tx.ExecContext(
ctx,
mergeSQL,
nOnMLNo,
row.NOnMLDetNo,
row.NHammaddeTuruNo,
strings.TrimSpace(row.SKodu),
strings.TrimSpace(row.SAciklama),
strings.TrimSpace(row.SRenk),
strings.TrimSpace(row.SBeden),
strings.TrimSpace(row.SAciklama2),
qty,
unitTRY,
lTutar,
bOtoFiyat,
lFireOrani,
nTutarGirisTipiNo,
sFiyatTipi,
"USD",
nKurTipiNo,
usdRate,
unitUSD,
strings.TrimSpace(row.SBirim),
lDovizTutari,
lEkMasraf,
lMiktar2,
nStokTipiID,
lEkFiyat,
row.NUrtMTBolumID,
row.FiyatGirilen,
strings.TrimSpace(row.FiyatDoviz),
row.MaliyeteDahil,
row.CMPriceTypeID,
user,
strings.TrimSpace(row.SAciklama3), // p32: sAciklama3 (Grup Adi)
); err != nil {
logger.Error("detail merge error",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"n_hammadde_turu_no", row.NHammaddeTuruNo,
"n_urt_mt_bolum_id", row.NUrtMTBolumID,
"s_kodu", strings.TrimSpace(row.SKodu),
"fiyat_girilen", row.FiyatGirilen,
"fiyat_doviz", strings.TrimSpace(row.FiyatDoviz),
"l_miktar", qty,
"n_stok_tipi_id", nStokTipiID,
"err", err,
)
http.Error(w, "Detay kaydedilemedi", http.StatusInternalServerError)
return
}
}
if skippedUpserts > 0 {
logger.Warn("detail upserts skipped summary (det_no<=0)", "trace_id", traceID, "n_onml_no", nOnMLNo, "skipped", skippedUpserts)
}
// Recipe sync (spUrtRecMBolum): insert missing rows, update qty when changed.
// IMPORTANT: We sync only variantless item codes (sModel-like) from OnML and never write CM2 items.
if req.Header.NUrtReceteID > 0 && len(recipeQtyByKey) > 0 {
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "recipe_sync", "n_urt_recete_id", req.Header.NUrtReceteID, "src_count", len(recipeQtyByKey))
// Determine default nUrtUBolumID from existing recipe rows; fallback to 13 (matches current data).
nUrtUBolumID := 13
_ = tx.QueryRowContext(ctx, `
SELECT TOP 1 ISNULL(CONVERT(int, nUrtUBolumID), 0) AS nUrtUBolumID
FROM dbo.spUrtRecMBolum WITH (NOLOCK)
WHERE nUrtReceteID = @p1
ORDER BY nUrtRecMBolumID ASC
`, req.Header.NUrtReceteID).Scan(&nUrtUBolumID)
if nUrtUBolumID <= 0 {
nUrtUBolumID = 13
}
// Load existing rows for quick compare.
existingQty := map[recipeKey]float64{}
if rows, err := tx.QueryContext(ctx, `
SELECT
ISNULL(CONVERT(int, nUrtMBolumID), 0) AS nUrtMBolumID,
LTRIM(RTRIM(ISNULL(nHStokID_G,''))) AS nHStokID_G,
ISNULL(CONVERT(float, lHMiktar_G), 0) AS lHMiktar_G
FROM dbo.spUrtRecMBolum WITH (NOLOCK)
WHERE nUrtReceteID = @p1
`, req.Header.NUrtReceteID); err == nil {
for rows.Next() {
var bolumID int
var code string
var q float64
if err := rows.Scan(&bolumID, &code, &q); err != nil {
continue
}
code = strings.TrimSpace(code)
if bolumID > 0 && code != "" {
existingQty[recipeKey{nUrtMBolumID: bolumID, sKodu: code}] = q
}
}
_ = rows.Close()
}
// Update changed quantities.
updated := 0
for k, q := range recipeQtyByKey {
old, ok := existingQty[k]
if !ok {
continue
}
if old == q {
continue
}
if _, err := tx.ExecContext(ctx, `
UPDATE dbo.spUrtRecMBolum
SET
lHMiktar_G = @p4,
sKullaniciAdiDeg = @p5,
dteIslemTarihiDeg = GETDATE()
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.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++
}
// Insert missing rows.
// We must generate nUrtRecMBolumID (smallint, non-identity) manually.
var baseID int
if err := tx.QueryRowContext(ctx, `
SELECT ISNULL(MAX(CONVERT(int, nUrtRecMBolumID)), 0) AS MaxID
FROM dbo.spUrtRecMBolum WITH (UPDLOCK, HOLDLOCK)
`).Scan(&baseID); err != nil {
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
for k, q := range recipeQtyByKey {
if _, ok := existingQty[k]; ok {
continue
}
// FK guard: only insert if nUrtMBolumID exists in spUrtMBolum.
var bolumExists int
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.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++
if nextID > 32767 {
logger.Warn("recipe insert stopped (nUrtRecMBolumID overflow)", "trace_id", traceID, "base_id", baseID, "next_id", nextID)
break
}
// NOTE: sIslemKodu is NOT NULL; keep empty string as default.
// Keep lMiktar_G at 0 (NOT NULL) to avoid producing NULL rows.
if _, err := tx.ExecContext(ctx, `
INSERT INTO dbo.spUrtRecMBolum (
nUrtRecMBolumID,
nUrtReceteID,
nUrtUBolumID,
nUrtMBolumID,
nUrtMTBolumID,
nStokTipiID,
nHStokID_G,
lHMiktar_G,
lHFire_G,
lHCarpan,
nMaliyetTipiID,
lHMaliyet_G,
nMTalimat_G,
bIslem,
lMiktar_G,
nSure,
sIslemKodu,
lHMiktar_GHedef,
nMBolumSarfTipiNo,
sKullaniciAdi,
dteIslemTarihi
)
VALUES (
@p1,@p2,@p3,@p4,
0,1,@p5,
@p6,0,1,
6,0,2,
0,0,0,
'',
0,1,
@p7,GETDATE()
)
`, nextID, req.Header.NUrtReceteID, nUrtUBolumID, k.nUrtMBolumID, k.sKodu, q, user); err != nil {
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++
}
logger.Info("recipe sync done", "trace_id", traceID, "n_onml_no", nOnMLNo, "n_urt_recete_id", req.Header.NUrtReceteID, "updated", updated, "inserted", inserted)
}
}
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "commit")
if err := tx.Commit(); err != nil {
logger.Error("tx commit error", "err", err)
http.Error(w, "Kaydetme tamamlanamadi", http.StatusInternalServerError)
return
}
committed = true
logger.Info("tx commit ok", "trace_id", traceID, "n_onml_no", nOnMLNo)
// 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{}
descByCode := map[string]string{}
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
// Best-effort description from UI payload (for excel export/readability).
if d := strings.TrimSpace(row.SAciklama); d != "" {
if _, ok := descByCode[code]; !ok {
descByCode[code] = d
}
}
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,
descByCode,
)
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, Warnings: warnings})
}
// POST /api/pricing/production-product-costing/onml/delete
// Deletes costing records created in URETIM (OnML header + details) and, if created by this app, V3 base price row.
// IMPORTANT: Recipe tables are NOT touched.
func PostProductionProductCostingOnMLDeleteHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
mssqlDB := db.GetDB()
claims, _ := auth.GetClaimsFromContext(r.Context())
user := ""
if claims != nil {
user = strings.TrimSpace(claims.Username)
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.onml.delete")
var req models.ProductionProductCostingOnMLDeleteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
if req.NOnMLNo <= 0 {
http.Error(w, "n_onml_no zorunlu", http.StatusBadRequest)
return
}
// Load header info for safe deletes and redirect behavior.
var urunKodu string
var maliyetTarihi time.Time
err := uretimDB.QueryRowContext(ctx, `
SELECT TOP 1
LTRIM(RTRIM(ISNULL(UrunKodu,''))) AS UrunKodu,
COALESCE(Tarihi, dteKayitTarihi, GETDATE()) AS Tarihi
FROM dbo.spUrtOnMLMas WITH (NOLOCK)
WHERE nOnMLNo = @p1
`, req.NOnMLNo).Scan(&urunKodu, &maliyetTarihi)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Kayit bulunamadi", http.StatusNotFound)
return
}
logger.Error("header lookup error", "err", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
urunKodu = strings.TrimSpace(urunKodu)
tx, err := uretimDB.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer func() { _ = tx.Rollback() }()
// Delete details first, then header.
if _, err := tx.ExecContext(ctx, `
DELETE FROM dbo.spUrtOnMLMasDet WHERE nOnMLNo = @p1
`, req.NOnMLNo); err != nil {
logger.Error("delete detail error", "err", err)
http.Error(w, "Detay silinemedi", http.StatusInternalServerError)
return
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM dbo.spUrtOnMLMas WHERE nOnMLNo = @p1
`, req.NOnMLNo); err != nil {
logger.Error("delete header error", "err", err)
http.Error(w, "Header silinemedi", http.StatusInternalServerError)
return
}
if err := tx.Commit(); err != nil {
logger.Error("tx commit error", "err", err)
http.Error(w, "Silme tamamlanamadi", http.StatusInternalServerError)
return
}
// V3: Delete base price rows we created for this costing date (PriceDate = maliyetTarihi).
// We intentionally do NOT delete older base prices for the same item.
// NOTE: UpsertV3ItemBasePriceUSD intentionally uses a non-TR CountryCode to avoid touching the original TR base price.
// Therefore delete must NOT filter by CountryCode='TR'; instead, delete by (ItemCode, PriceDate, Currency=USD, BasePriceCode=1)
// and only if it was created/updated by this app (Created/LastUpdated starts with BSSAPP).
deletedBasePrice := false
deletedBasePriceCount := 0
if mssqlDB != nil && urunKodu != "" {
priceDate := maliyetTarihi.Format("2006-01-02")
// Delete only rows owned by this app (Created/LastUpdated starts with BSSAPP).
res, err := mssqlDB.ExecContext(ctx, `
DELETE FROM dbo.prItemBasePrice
WHERE ItemTypeCode = 1
AND LTRIM(RTRIM(ItemCode)) = @p1
AND ISNULL(SeasonCode,'') = ''
AND ISNULL(BasePriceCode,0) = 1
AND CONVERT(date, PriceDate) = CONVERT(date, @p2, 23)
AND LTRIM(RTRIM(ISNULL(CurrencyCode,''))) = 'USD'
AND (
UPPER(LTRIM(RTRIM(ISNULL(CreatedUserName,'')))) LIKE 'BSSAPP%'
OR UPPER(LTRIM(RTRIM(ISNULL(LastUpdatedUserName,'')))) LIKE 'BSSAPP%'
)
`, urunKodu, priceDate)
if err != nil {
logger.Warn("v3 base price delete failed", "err", err, "urun_kodu", urunKodu, "price_date", priceDate)
} else {
if rows, rerr := res.RowsAffected(); rerr == nil {
deletedBasePriceCount = int(rows)
deletedBasePrice = deletedBasePriceCount > 0
} else {
// Unknown affected rows, still mark as attempted.
deletedBasePrice = true
}
}
}
logger.Info("delete done", "n_onml_no", req.NOnMLNo, "urun_kodu", urunKodu, "deleted_base_price", deletedBasePrice, "deleted_base_price_count", deletedBasePriceCount, "user", user)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"n_onml_no": req.NOnMLNo,
"urun_kodu": urunKodu,
"deleted_baseprice": deletedBasePrice,
"deleted_baseprice_count": deletedBasePriceCount,
})
}
// GET /api/pricing/production-product-costing/has-cost-detail-exchange-rates
func GetProductionHasCostDetailExchangeRatesHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
mssqlDB := db.GetDB()
if mssqlDB == nil {
http.Error(w, "MSSQL veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.exchange-rates")
rawCostDate := strings.TrimSpace(r.URL.Query().Get("maliyet_tarihi"))
costDate := ""
if rawCostDate != "" {
parsedDate, err := time.Parse("2006-01-02", rawCostDate)
if err != nil {
logger.Warn("request invalid", "reason", "invalid maliyet_tarihi", "maliyet_tarihi", rawCostDate)
http.Error(w, "maliyet_tarihi YYYY-MM-DD formatinda olmali", http.StatusBadRequest)
return
}
costDate = parsedDate.Format("2006-01-02")
}
logger.Info("request start", "maliyet_tarihi", costDate)
log.Printf("[ProductionHasCostDetailExchangeRates] start maliyet_tarihi=%s", costDate)
row, err := queries.GetProductionHasCostDetailExchangeRatesByDate(ctx, mssqlDB, costDate)
if err != nil {
logger.Error("query prepare error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailExchangeRates] query prepare error: %v", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
item := models.ProductionHasCostDetailExchangeRates{
TRYRate: 1,
}
if err := row.Scan(
&item.RateDate,
&item.USDRate,
&item.EURRate,
&item.GBPRate,
); err != nil {
if err == sql.ErrNoRows {
item.RateDate = costDate
logger.Info("request done", "rate_date", item.RateDate, "fallback", true)
_ = json.NewEncoder(w).Encode(item)
return
}
log.Printf("⚠️ [ProductionHasCostDetailExchangeRates] scan error: %v", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "rate_date", item.RateDate, "usd_rate", item.USDRate, "eur_rate", item.EURRate, "gbp_rate", item.GBPRate)
log.Printf("[ProductionHasCostDetailExchangeRates] done maliyet_tarihi=%s rate_date=%s usd=%.4f eur=%.4f", costDate, item.RateDate, item.USDRate, item.EURRate)
_ = json.NewEncoder(w).Encode(item)
}
// POST /api/pricing/production-product-costing/has-cost-detail-bulk-prices
func PostProductionHasCostDetailBulkPricesHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
mssqlDB := db.GetDB()
if mssqlDB == nil {
http.Error(w, "MSSQL veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.bulk-prices")
var req models.ProductionHasCostDetailBulkPriceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logger.Warn("request invalid", "reason", "invalid request body", "err", err)
http.Error(w, "Gecersiz istek govdesi", http.StatusBadRequest)
return
}
costDate := strings.TrimSpace(req.MaliyetTarihi)
itemsCount := len(req.Items)
logger.Info("request start",
"n_onml_no", strings.TrimSpace(req.NOnMLNo),
"urun_kodu", strings.TrimSpace(req.UrunKodu),
"maliyet_tarihi", costDate,
"item_count", itemsCount,
)
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)
// 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
}
}
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 := rows.Scan(
&rowKey,
&result.PriceType,
&result.Tarih,
&result.FaturaKodu,
&result.MasrafKodu,
&result.MasrafDetay,
&result.ColorCode,
&result.ColorDescription,
&result.ItemDim1Code,
&result.ItemDim1Description,
&result.FiyatGirilen,
&result.FiyatDoviz,
); err != nil {
logger.Warn("bulk scan error", "err", err)
continue
}
meta := metaByRowKey[rowKey]
result.RowKey = strings.TrimSpace(meta.RowKey)
if result.RowKey == "" {
result.RowKey = rowKey
}
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)
}
}
logger.Info("request done", "item_count", itemsCount, "matched_count", len(response))
log.Printf("[ProductionHasCostDetailBulkPrices] done item_count=%d matched=%d", itemsCount, len(response))
_ = json.NewEncoder(w).Encode(map[string]any{
"items": response,
})
}
// GET /api/pricing/production-product-costing/has-cost-detail-line-history
func GetProductionHasCostDetailLineHistoryHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
mssqlDB := db.GetDB()
if uretimDB == nil && mssqlDB == nil {
http.Error(w, "Veritabani baglantilari aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.line-history")
rawOnMLNo := strings.TrimSpace(r.URL.Query().Get("n_onml_no"))
currentOnMLNo, _ := strconv.Atoi(rawOnMLNo)
nHammaddeTuruNo := strings.TrimSpace(r.URL.Query().Get("n_hammadde_turu_no"))
sKodu := normalizeLookupValue(r.URL.Query().Get("s_kodu"))
colorCode := firstNonEmptyString(
normalizeLookupValue(r.URL.Query().Get("color_code")),
normalizeLookupValue(r.URL.Query().Get("s_renk")),
)
costDate := strings.TrimSpace(r.URL.Query().Get("maliyet_tarihi"))
if sKodu == "" {
logger.Warn("request invalid", "reason", "missing s_kodu")
http.Error(w, "s_kodu zorunlu", http.StatusBadRequest)
return
}
logger.Info("request start", "n_onml_no", currentOnMLNo, "n_hammadde_turu_no", nHammaddeTuruNo, "s_kodu", sKodu, "color_code", colorCode, "maliyet_tarihi", costDate)
log.Printf("[ProductionHasCostDetailLineHistory] start n_onml_no=%d n_hammadde_turu_no=%s s_kodu=%s color=%s maliyet_tarihi=%s", currentOnMLNo, nHammaddeTuruNo, sKodu, colorCode, costDate)
const LINE_HISTORY_ROW_LIMIT = 500
response := models.ProductionHasCostDetailLineHistoryResponse{
PurchaseRows: make([]models.ProductionHasCostDetailPurchaseHistoryRow, 0, LINE_HISTORY_ROW_LIMIT),
RecipeRows: make([]models.ProductionHasCostDetailRecipeHistoryRow, 0, LINE_HISTORY_ROW_LIMIT),
}
allowLegacyAutoFallback := true
if mssqlDB != nil {
rows, err := queries.GetProductionHasCostPurchaseHistoryByExpenseCode(
ctx,
mssqlDB,
sKodu,
costDate,
LINE_HISTORY_ROW_LIMIT,
)
if err != nil {
logger.Warn("purchase query error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] purchase query error: %v", err)
} else {
defer rows.Close()
for rows.Next() {
var item models.ProductionHasCostDetailPurchaseHistoryRow
if err := rows.Scan(
&item.SourceType,
&item.PriceType,
&item.Tarih,
&item.FaturaKodu,
&item.FirmaKodu,
&item.FirmaAciklama,
&item.MasrafKodu,
&item.MasrafDetay,
&item.ColorCode,
&item.ColorDescription,
&item.ItemDim1Code,
&item.ItemDim1Description,
&item.Miktar,
&item.BIRIM,
&item.EvrakFiyat,
&item.EvrakTutar,
&item.EvrakDoviz,
); err != nil {
logger.Warn("purchase scan error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] purchase scan error: %v", err)
continue
}
response.PurchaseRows = append(response.PurchaseRows, item)
}
if err := rows.Err(); err != nil {
logger.Warn("purchase rows error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] purchase rows error: %v", err)
}
}
}
if allowLegacyAutoFallback && mssqlDB != nil && len(response.PurchaseRows) == 0 {
similarPrefix := queries.BuildProductionHasCostSimilarCodePrefix(sKodu)
if similarPrefix != "" {
logger.Info("purchase fallback start", "s_kodu", sKodu, "similar_prefix", similarPrefix, "maliyet_tarihi", costDate)
rows, err := queries.GetProductionHasCostPurchaseHistoryByCodePrefix(
ctx,
mssqlDB,
similarPrefix,
costDate,
LINE_HISTORY_ROW_LIMIT,
)
if err != nil {
logger.Warn("purchase fallback query error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] purchase fallback query error: %v", err)
} else {
defer rows.Close()
for rows.Next() {
var item models.ProductionHasCostDetailPurchaseHistoryRow
if err := rows.Scan(
&item.SourceType,
&item.PriceType,
&item.Tarih,
&item.FaturaKodu,
&item.FirmaKodu,
&item.FirmaAciklama,
&item.MasrafKodu,
&item.MasrafDetay,
&item.ColorCode,
&item.ColorDescription,
&item.ItemDim1Code,
&item.ItemDim1Description,
&item.Miktar,
&item.BIRIM,
&item.EvrakFiyat,
&item.EvrakTutar,
&item.EvrakDoviz,
); err != nil {
logger.Warn("purchase fallback scan error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] purchase fallback scan error: %v", err)
continue
}
response.PurchaseRows = append(response.PurchaseRows, item)
}
if err := rows.Err(); err != nil {
logger.Warn("purchase fallback rows error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] purchase fallback rows error: %v", err)
}
}
}
}
if uretimDB != nil {
rows, err := queries.GetProductionHasCostRecipeHistoryByExpenseCode(
ctx,
uretimDB,
currentOnMLNo,
sKodu,
colorCode,
costDate,
LINE_HISTORY_ROW_LIMIT,
)
if err != nil {
logger.Warn("recipe query error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] recipe query error: %v", err)
} else {
defer rows.Close()
for rows.Next() {
var item models.ProductionHasCostDetailRecipeHistoryRow
if err := rows.Scan(
&item.SourceType,
&item.PriceType,
&item.DteIslemTarihi,
&item.NOnMLNo,
&item.FirmaKodu,
&item.FirmaAciklama,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
&item.LMiktar,
&item.SBirim,
&item.LDovizFiyati,
&item.LDovizTutari,
&item.USD,
&item.DUMMY,
); err != nil {
logger.Warn("recipe scan error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] recipe scan error: %v", err)
continue
}
response.RecipeRows = append(response.RecipeRows, item)
}
if err := rows.Err(); err != nil {
logger.Warn("recipe rows error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] recipe rows error: %v", err)
}
}
}
if allowLegacyAutoFallback && uretimDB != nil && len(response.RecipeRows) == 0 {
similarPrefix := queries.BuildProductionHasCostSimilarCodePrefix(sKodu)
if similarPrefix != "" {
logger.Info("recipe fallback prefix start", "s_kodu", sKodu, "similar_prefix", similarPrefix, "maliyet_tarihi", costDate)
rows, err := queries.GetProductionHasCostOnMLHistoryByCodePrefix(
ctx,
uretimDB,
similarPrefix,
costDate,
LINE_HISTORY_ROW_LIMIT,
)
if err != nil {
logger.Warn("recipe fallback prefix query error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] recipe fallback prefix query error: %v", err)
} else {
defer rows.Close()
for rows.Next() {
var item models.ProductionHasCostDetailRecipeHistoryRow
if err := rows.Scan(
&item.SourceType,
&item.PriceType,
&item.DteIslemTarihi,
&item.NOnMLNo,
&item.FirmaKodu,
&item.FirmaAciklama,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
&item.LMiktar,
&item.SBirim,
&item.LDovizFiyati,
&item.LDovizTutari,
&item.USD,
&item.DUMMY,
); err != nil {
logger.Warn("recipe fallback prefix scan error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] recipe fallback prefix scan error: %v", err)
continue
}
response.RecipeRows = append(response.RecipeRows, item)
}
if err := rows.Err(); err != nil {
logger.Warn("recipe fallback prefix rows error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] recipe fallback prefix rows error: %v", err)
}
}
}
}
if allowLegacyAutoFallback && uretimDB != nil && len(response.RecipeRows) == 0 && nHammaddeTuruNo != "" {
logger.Info("recipe fallback hammadde-turu start", "n_hammadde_turu_no", nHammaddeTuruNo, "maliyet_tarihi", costDate)
rows, err := queries.GetProductionHasCostOnMLHistoryByHammaddeTuruNo(
ctx,
uretimDB,
nHammaddeTuruNo,
costDate,
LINE_HISTORY_ROW_LIMIT,
)
if err != nil {
logger.Warn("recipe fallback hammadde-turu query error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] recipe fallback hammadde-turu query error: %v", err)
} else {
defer rows.Close()
for rows.Next() {
var item models.ProductionHasCostDetailRecipeHistoryRow
if err := rows.Scan(
&item.SourceType,
&item.PriceType,
&item.DteIslemTarihi,
&item.NOnMLNo,
&item.FirmaKodu,
&item.FirmaAciklama,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
&item.LMiktar,
&item.SBirim,
&item.LDovizFiyati,
&item.LDovizTutari,
&item.USD,
&item.DUMMY,
); err != nil {
logger.Warn("recipe fallback hammadde-turu scan error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] recipe fallback hammadde-turu scan error: %v", err)
continue
}
response.RecipeRows = append(response.RecipeRows, item)
}
if err := rows.Err(); err != nil {
logger.Warn("recipe fallback hammadde-turu rows error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailLineHistory] recipe fallback hammadde-turu rows error: %v", err)
}
}
}
logger.Info("request done", "s_kodu", sKodu, "purchase_count", len(response.PurchaseRows), "recipe_count", len(response.RecipeRows))
log.Printf("[ProductionHasCostDetailLineHistory] done s_kodu=%s purchase_rows=%d recipe_rows=%d", sKodu, len(response.PurchaseRows), len(response.RecipeRows))
_ = json.NewEncoder(w).Encode(response)
}
// GET /api/pricing/production-product-costing/has-cost-detail-similar-history
func GetProductionHasCostDetailSimilarHistoryHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
mssqlDB := db.GetDB()
uretimDB := db.GetUretimDB()
if mssqlDB == nil || uretimDB == nil {
http.Error(w, "Veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.similar-history")
q := r.URL.Query()
nHammaddeTuruNo := strings.TrimSpace(q.Get("n_hammadde_turu_no"))
sKodu := normalizeLookupValue(q.Get("s_kodu"))
costDate := strings.TrimSpace(q.Get("maliyet_tarihi"))
limit := parsePositiveIntOrDefault(q.Get("limit"), 500)
searchMode := strings.ToLower(strings.TrimSpace(q.Get("search_mode")))
if searchMode == "" || searchMode == "exact" {
searchMode = "prefix"
}
similarPrefix := queries.BuildProductionHasCostSimilarCodePrefix(sKodu)
if nHammaddeTuruNo == "" && sKodu == "" {
http.Error(w, "n_hammadde_turu_no veya s_kodu parametresi zorunludur", http.StatusBadRequest)
return
}
logger.Info("request start", "search_mode", searchMode, "n_hammadde_turu_no", nHammaddeTuruNo, "s_kodu", sKodu, "similar_prefix", similarPrefix, "maliyet_tarihi", costDate, "limit", limit)
response := models.ProductionHasCostDetailLineHistoryResponse{
PurchaseRows: make([]models.ProductionHasCostDetailPurchaseHistoryRow, 0, limit),
RecipeRows: make([]models.ProductionHasCostDetailRecipeHistoryRow, 0, limit),
}
purchaseMatchStage := searchMode
recipeMatchStage := searchMode
allowRecipeAutoFallback := false
if searchMode == "alternative" {
purchaseMatchStage = "skipped"
if nHammaddeTuruNo != "" && uretimDB != nil {
rows, err := queries.GetProductionHasCostOnMLHistoryByHammaddeTuruNo(ctx, uretimDB, nHammaddeTuruNo, costDate, limit)
if err != nil {
logger.Warn("alternative onml query error", "err", err)
http.Error(w, "Sorgu calistirilirken hata olustu", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var item models.ProductionHasCostDetailRecipeHistoryRow
if err := rows.Scan(
&item.SourceType,
&item.PriceType,
&item.DteIslemTarihi,
&item.NOnMLNo,
&item.FirmaKodu,
&item.FirmaAciklama,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
&item.LMiktar,
&item.SBirim,
&item.LDovizFiyati,
&item.LDovizTutari,
&item.USD,
&item.DUMMY,
); err != nil {
logger.Warn("alternative onml scan error", "err", err)
continue
}
response.RecipeRows = append(response.RecipeRows, item)
}
if err := rows.Err(); err != nil {
logger.Warn("alternative onml rows error", "err", err)
}
}
if len(response.PurchaseRows) == 0 {
purchaseMatchStage = "empty"
}
if len(response.RecipeRows) == 0 {
recipeMatchStage = "empty"
}
logger.Info("request done",
"search_mode", searchMode,
"purchase_match_stage", purchaseMatchStage,
"recipe_match_stage", recipeMatchStage,
"similar_prefix", similarPrefix,
"purchase_count", len(response.PurchaseRows),
"recipe_count", len(response.RecipeRows),
)
_ = json.NewEncoder(w).Encode(map[string]any{
"purchaseRows": response.PurchaseRows,
"recipeRows": response.RecipeRows,
"purchase_match_stage": purchaseMatchStage,
"recipe_match_stage": recipeMatchStage,
"similar_code_prefix": similarPrefix,
"search_mode": searchMode,
})
return
}
if similarPrefix != "" {
if mssqlDB != nil {
rows, err := queries.GetProductionHasCostPurchaseHistoryByCodePrefix(ctx, mssqlDB, similarPrefix, costDate, limit)
if err != nil {
logger.Warn("prefix purchase query error", "err", err)
} else {
defer rows.Close()
for rows.Next() {
var item models.ProductionHasCostDetailPurchaseHistoryRow
if err := rows.Scan(
&item.SourceType,
&item.PriceType,
&item.Tarih,
&item.FaturaKodu,
&item.FirmaKodu,
&item.FirmaAciklama,
&item.MasrafKodu,
&item.MasrafDetay,
&item.ColorCode,
&item.ColorDescription,
&item.ItemDim1Code,
&item.ItemDim1Description,
&item.Miktar,
&item.BIRIM,
&item.EvrakFiyat,
&item.EvrakTutar,
&item.EvrakDoviz,
); err != nil {
logger.Warn("prefix purchase scan error", "err", err)
continue
}
response.PurchaseRows = append(response.PurchaseRows, item)
}
if err := rows.Err(); err != nil {
logger.Warn("prefix purchase rows error", "err", err)
}
}
}
if uretimDB != nil {
rows, err := queries.GetProductionHasCostOnMLHistoryByCodePrefix(ctx, uretimDB, similarPrefix, costDate, limit)
if err != nil {
logger.Warn("prefix onml query error", "err", err)
} else {
defer rows.Close()
for rows.Next() {
var item models.ProductionHasCostDetailRecipeHistoryRow
if err := rows.Scan(
&item.SourceType,
&item.PriceType,
&item.DteIslemTarihi,
&item.NOnMLNo,
&item.FirmaKodu,
&item.FirmaAciklama,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
&item.LMiktar,
&item.SBirim,
&item.LDovizFiyati,
&item.LDovizTutari,
&item.USD,
&item.DUMMY,
); err != nil {
logger.Warn("prefix onml scan error", "err", err)
continue
}
response.RecipeRows = append(response.RecipeRows, item)
}
if err := rows.Err(); err != nil {
logger.Warn("prefix onml rows error", "err", err)
}
}
}
}
if allowRecipeAutoFallback && len(response.RecipeRows) == 0 && nHammaddeTuruNo != "" && uretimDB != nil {
recipeMatchStage = "hammadde-turu-fallback"
rows, err := queries.GetProductionHasCostOnMLHistoryByHammaddeTuruNo(ctx, uretimDB, nHammaddeTuruNo, costDate, limit)
if err != nil {
logger.Warn("fallback onml query error", "err", err)
http.Error(w, "Sorgu calistirilirken hata olustu", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var item models.ProductionHasCostDetailRecipeHistoryRow
if err := rows.Scan(
&item.SourceType,
&item.PriceType,
&item.DteIslemTarihi,
&item.NOnMLNo,
&item.FirmaKodu,
&item.FirmaAciklama,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
&item.LMiktar,
&item.SBirim,
&item.LDovizFiyati,
&item.LDovizTutari,
&item.USD,
&item.DUMMY,
); err != nil {
logger.Warn("fallback onml scan error", "err", err)
continue
}
response.RecipeRows = append(response.RecipeRows, item)
}
if err := rows.Err(); err != nil {
logger.Warn("fallback onml rows error", "err", err)
}
}
if len(response.PurchaseRows) == 0 {
purchaseMatchStage = "empty"
}
if len(response.RecipeRows) == 0 {
recipeMatchStage = "empty"
}
logger.Info("request done",
"search_mode", searchMode,
"purchase_match_stage", purchaseMatchStage,
"recipe_match_stage", recipeMatchStage,
"similar_prefix", similarPrefix,
"purchase_count", len(response.PurchaseRows),
"recipe_count", len(response.RecipeRows),
)
_ = json.NewEncoder(w).Encode(map[string]any{
"purchaseRows": response.PurchaseRows,
"recipeRows": response.RecipeRows,
"purchase_match_stage": purchaseMatchStage,
"recipe_match_stage": recipeMatchStage,
"similar_code_prefix": similarPrefix,
"search_mode": searchMode,
})
}
func parsePositiveIntOrDefault(raw string, fallback int) int {
v, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || v < 0 {
return fallback
}
return v
}
func normalizeLookupValue(raw string) string {
return strings.ToUpper(strings.TrimSpace(raw))
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
// ============================================================
// MT BOLUM MAPPING (URETIM DB)
// ============================================================
// GET /api/pricing/production-product-costing/options/urun-ana-grup
func GetProductionProductCostingUrunAnaGrupOptionsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
mssqlDB := db.GetDB()
if mssqlDB == nil {
http.Error(w, "MSSQL veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
search := strings.TrimSpace(r.URL.Query().Get("search"))
limit := parsePositiveIntOrDefault(r.URL.Query().Get("limit"), 50)
if limit > 500 {
limit = 500
}
logger := utils.SlogFromContext(ctx).With(
"handler", "production-product-costing.options.urun-ana-grup",
"search", search,
"limit", limit,
)
logger.Info("request start")
rows, err := queries.GetProductionProductCostingAnaGrupOptions(ctx, mssqlDB, search, limit)
if err != nil {
logger.Error("query error", "err", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
out := make([]models.ProductionProductCostingLookupOption, 0, limit)
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
logger.Warn("scan error", "err", err)
continue
}
v = strings.TrimSpace(v)
if v == "" {
continue
}
out = append(out, models.ProductionProductCostingLookupOption{Value: v, Label: v})
}
if err := rows.Err(); err != nil {
logger.Error("rows error", "err", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "row_count", len(out))
_ = json.NewEncoder(w).Encode(out)
}
// GET /api/pricing/production-product-costing/options/urun-alt-grup
func GetProductionProductCostingUrunAltGrupOptionsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
mssqlDB := db.GetDB()
if mssqlDB == nil {
http.Error(w, "MSSQL veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
urunAnaGrubu := strings.TrimSpace(r.URL.Query().Get("urun_ana_grubu"))
search := strings.TrimSpace(r.URL.Query().Get("search"))
limit := parsePositiveIntOrDefault(r.URL.Query().Get("limit"), 50)
if limit > 500 {
limit = 500
}
logger := utils.SlogFromContext(ctx).With(
"handler", "production-product-costing.options.urun-alt-grup",
"urun_ana_grubu", urunAnaGrubu,
"search", search,
"limit", limit,
)
logger.Info("request start")
rows, err := queries.GetProductionProductCostingAltGrupOptions(ctx, mssqlDB, urunAnaGrubu, search, limit)
if err != nil {
logger.Error("query error", "err", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
out := make([]models.ProductionProductCostingLookupOption, 0, limit)
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
logger.Warn("scan error", "err", err)
continue
}
v = strings.TrimSpace(v)
if v == "" {
continue
}
out = append(out, models.ProductionProductCostingLookupOption{Value: v, Label: v})
}
if err := rows.Err(); err != nil {
logger.Error("rows error", "err", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "row_count", len(out))
_ = json.NewEncoder(w).Encode(out)
}
// GET /api/pricing/production-product-costing/options/urun-ana-alt-combos
func GetProductionProductCostingUrunAnaAltCombosHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
mssqlDB := db.GetDB()
if mssqlDB == nil {
http.Error(w, "MSSQL veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
search := strings.TrimSpace(r.URL.Query().Get("search"))
limit := parsePositiveIntOrDefault(r.URL.Query().Get("limit"), 2000)
if limit > 5000 {
limit = 5000
}
logger := utils.SlogFromContext(ctx).With(
"handler", "production-product-costing.options.urun-ana-alt-combos",
"search", search,
"limit", limit,
)
logger.Info("request start")
rows, err := queries.GetProductionProductCostingAnaAltComboRows(ctx, mssqlDB, search, limit)
if err != nil {
// Keep the response generic, but log the underlying SQL driver error for diagnostics.
logger.Error("query error", "err", err, "search", search, "limit", limit, "trace_id", traceID)
log.Printf("❌ [ProductionProductCostingAnaAltCombos] query error trace_id=%s search=%q limit=%d err=%v", traceID, search, limit, err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
out := make([]models.ProductionProductCostingAnaAltComboRow, 0, 1024)
for rows.Next() {
var item models.ProductionProductCostingAnaAltComboRow
if err := rows.Scan(&item.UrunIlkGrubu, &item.UrunAnaGrubu, &item.UrunAltGrubu); err != nil {
logger.Warn("scan error", "err", err)
continue
}
item.UrunIlkGrubu = strings.TrimSpace(item.UrunIlkGrubu)
item.UrunAnaGrubu = strings.TrimSpace(item.UrunAnaGrubu)
item.UrunAltGrubu = strings.TrimSpace(item.UrunAltGrubu)
if item.UrunIlkGrubu == "" || item.UrunAnaGrubu == "" || item.UrunAltGrubu == "" {
continue
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
logger.Error("rows error", "err", err, "trace_id", traceID)
log.Printf("⚠️ [ProductionProductCostingAnaAltCombos] rows error trace_id=%s err=%v", traceID, err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "row_count", len(out))
_ = json.NewEncoder(w).Encode(out)
}
// GET /api/pricing/production-product-costing/options/mtbolum
func GetProductionProductCostingMTBolumOptionsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
search := strings.TrimSpace(r.URL.Query().Get("search"))
limit := parsePositiveIntOrDefault(r.URL.Query().Get("limit"), 50)
if limit > 500 {
limit = 500
}
logger := utils.SlogFromContext(ctx).With(
"handler", "production-product-costing.options.mtbolum",
"search", search,
"limit", limit,
)
logger.Info("request start")
rows, err := queries.GetProductionProductCostingMTBolumOptions(ctx, uretimDB, search, limit)
if err != nil {
logger.Error("query error", "err", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
out := make([]models.ProductionProductCostingLookupOption, 0, limit)
for rows.Next() {
var id int
var name string
if err := rows.Scan(&id, &name); err != nil {
logger.Warn("scan error", "err", err)
continue
}
label := strings.TrimSpace(strconv.Itoa(id) + " - " + strings.TrimSpace(name))
out = append(out, models.ProductionProductCostingLookupOption{
Value: strconv.Itoa(id),
Label: label,
})
}
if err := rows.Err(); err != nil {
logger.Error("rows error", "err", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "row_count", len(out))
_ = json.NewEncoder(w).Encode(out)
}
// GET /api/pricing/production-product-costing/maliyet-parca-eslestirme
func GetProductionProductCostingParcaMappingsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
urunIlkGrubu := strings.TrimSpace(r.URL.Query().Get("urun_ilk_grubu"))
urunAnaGrubu := strings.TrimSpace(r.URL.Query().Get("urun_ana_grubu"))
urunAltGrubu := strings.TrimSpace(r.URL.Query().Get("urun_alt_grubu"))
nUrtMTBolumID := parsePositiveIntOrDefault(r.URL.Query().Get("n_urt_mt_bolum_id"), 0)
rawOnlyActive := strings.TrimSpace(r.URL.Query().Get("only_active"))
var onlyActive *bool = nil
if rawOnlyActive != "" {
v := rawOnlyActive == "1" || strings.EqualFold(rawOnlyActive, "true")
onlyActive = &v
}
logger := utils.SlogFromContext(ctx).With(
"handler", "production-product-costing.maliyet-parca-eslestirme.list",
"urun_ilk_grubu", urunIlkGrubu,
"urun_ana_grubu", urunAnaGrubu,
"urun_alt_grubu", urunAltGrubu,
"n_urt_mt_bolum_id", nUrtMTBolumID,
"only_active", rawOnlyActive,
)
logger.Info("request start")
rows, err := queries.ListProductionProductCostingParcaMappings(ctx, uretimDB, urunIlkGrubu, urunAnaGrubu, urunAltGrubu, nUrtMTBolumID, onlyActive)
if err != nil {
logger.Error("query error", "err", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
defer rows.Close()
out := make([]models.ProductionProductCostingParcaMappingRow, 0, 200)
for rows.Next() {
var row models.ProductionProductCostingParcaMappingRow
var bAktif sql.NullBool
var hammaddeCsv sql.NullString
if err := rows.Scan(
&row.ID,
&row.UrunIlkGrubu,
&row.UrunAnaGrubu,
&row.UrunAltGrubu,
&row.NUrtMTBolumID,
&row.ParcaBolumAdi,
&hammaddeCsv,
&bAktif,
&row.DteIslem,
&row.SKullaniciAdi,
); err != nil {
logger.Warn("scan error", "err", err)
continue
}
row.BAktif = bAktif.Valid && bAktif.Bool
// Normalize legacy/duplicate hammadde type numbers so UI doesn't miss required CM2 slots.
// Some environments have both inactive and active equivalents (e.g. 463->500, 464->3900, 466->12700).
normalizeHNo := func(v int) int {
switch v {
case 108, 463:
return 500 // CKT CM2
case 109, 464:
return 3900 // PNT CM2
case 110, 465:
return 8300 // YLK CM2
case 466:
return 12700 // YKA CM2
case 467:
return 12100 // AKS CM2
case 468:
return 13500 // GML CM2
case 488:
return 15300 // KBN CM2
case 493:
return 15301 // MNT CM2
default:
return v
}
}
seenH := make(map[int]struct{}, 16)
row.NHammaddeTurleri = make([]string, 0, 8)
if hammaddeCsv.Valid {
for _, part := range strings.Split(hammaddeCsv.String, ",") {
part = strings.TrimSpace(part)
if part != "" {
n, err := strconv.Atoi(part)
if err != nil {
// keep as-is
row.NHammaddeTurleri = append(row.NHammaddeTurleri, part)
continue
}
n = normalizeHNo(n)
if n <= 0 {
continue
}
if _, ok := seenH[n]; ok {
continue
}
seenH[n] = struct{}{}
row.NHammaddeTurleri = append(row.NHammaddeTurleri, strconv.Itoa(n))
}
}
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
logger.Error("rows error", "err", err)
http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "row_count", len(out))
_ = json.NewEncoder(w).Encode(out)
}
// POST /api/pricing/production-product-costing/maliyet-parca-eslestirme/upsert
func PostProductionProductCostingParcaMappingUpsertHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
claims, _ := auth.GetClaimsFromContext(r.Context())
user := ""
if claims != nil {
user = strings.TrimSpace(claims.Username)
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.maliyet-parca-eslestirme.upsert")
logger.Info("request start")
var req models.ProductionProductCostingParcaMappingUpsertRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logger.Warn("invalid json", "err", err)
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
req.UrunAnaGrubu = strings.TrimSpace(req.UrunAnaGrubu)
req.UrunAltGrubu = strings.TrimSpace(req.UrunAltGrubu)
req.UrunIlkGrubu = strings.TrimSpace(req.UrunIlkGrubu)
if req.UrunIlkGrubu == "" || req.UrunAnaGrubu == "" || req.UrunAltGrubu == "" || req.NUrtMTBolumID <= 0 {
http.Error(w, "urunIlkGrubu, urunAnaGrubu, urunAltGrubu ve nUrtMTBolumID zorunlu", http.StatusBadRequest)
return
}
allowed, invalid, ferr := queries.FilterHammaddeTurleriForPart(ctx, uretimDB, req.NUrtMTBolumID, req.NHammaddeTurleri)
if ferr != nil {
logger.Error("hammadde validation error", "err", ferr)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
// Soft-enforce: drop invalid types instead of hard failing. This prevents a "multi-part save" from failing
// when the UI reuses the same hammadde list across multiple MT bolum selections.
// We still return the ignored list so UI/logs can surface it.
ignored := invalid
id, err := queries.UpsertProductionProductCostingParcaMapping(ctx, uretimDB, req.UrunIlkGrubu, req.UrunAnaGrubu, req.UrunAltGrubu, req.NUrtMTBolumID, allowed, req.BAktif, user)
if err != nil {
logger.Error("exec error", "err", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "id", id, "user", user, "bAktif", req.BAktif)
if len(ignored) > 0 {
logger.Warn("hammadde types ignored due to MT bolum mismatch", "nUrtMTBolumID", req.NUrtMTBolumID, "ignored_count", len(ignored))
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": id, "ignored": ignored})
}
type productionProductCostingSetActiveRequest struct {
ID int `json:"id"`
BAktif bool `json:"bAktif"`
}
// POST /api/pricing/production-product-costing/maliyet-parca-eslestirme/set-active
func PostProductionProductCostingParcaMappingSetActiveHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
claims, _ := auth.GetClaimsFromContext(r.Context())
user := ""
if claims != nil {
user = strings.TrimSpace(claims.Username)
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.maliyet-parca-eslestirme.set-active")
logger.Info("request start")
var req productionProductCostingSetActiveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logger.Warn("invalid json", "err", err)
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
if req.ID <= 0 {
http.Error(w, "id zorunlu", http.StatusBadRequest)
return
}
if err := queries.SetProductionProductCostingParcaMappingActive(ctx, uretimDB, req.ID, req.BAktif, user); err != nil {
logger.Error("exec error", "err", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "id", req.ID, "bAktif", req.BAktif, "user", user)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// DELETE /api/pricing/production-product-costing/maliyet-parca-eslestirme?id=123
func DeleteProductionProductCostingParcaMappingHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.maliyet-parca-eslestirme.delete")
logger.Info("request start")
id := parsePositiveIntOrDefault(r.URL.Query().Get("id"), 0)
if id <= 0 {
http.Error(w, "id zorunlu", http.StatusBadRequest)
return
}
if err := queries.DeleteProductionProductCostingParcaMapping(ctx, uretimDB, id); err != nil {
logger.Error("exec error", "err", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
logger.Info("request done", "id", id)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}