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 } // Fill missing descriptions from MSSQL (best-effort, small set). { miss := make([]string, 0, len(codes)) for _, c := range codes { if strings.TrimSpace(descByCode[c]) == "" { miss = append(miss, c) } } if len(miss) > 0 { type res struct{ code, desc string } jobs := make(chan string, len(miss)) out := make(chan res, len(miss)) worker := func() { for code := range jobs { stepCtx, cancel := context.WithTimeout(bg, 2*time.Second) d, _ := queries.GetItemDescriptionTRByItemCode(stepCtx, mssqlLocal, code) cancel() out <- res{code: code, desc: d} } } workers := 8 for i := 0; i < workers; i++ { go worker() } for _, c := range miss { jobs <- c } close(jobs) for i := 0; i < len(miss); i++ { r := <-out if strings.TrimSpace(r.desc) != "" { descByCode[r.code] = r.desc } } } } 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}) }