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 {
		// The top header in the detail screen only includes rows marked
		// "Maliyete Dahil". Persist that exact business total to the master
		// record as well; excluded detail rows must not inflate the cost.
		if r.MaliyeteDahil != 1 {
			continue
		}
		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 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
    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
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
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
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
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 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
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})
}
