Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-05-20 20:20:10 +03:00
parent 9b4b82dd52
commit c1c1ed99c7
6 changed files with 1123 additions and 261 deletions

View File

@@ -767,7 +767,15 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht
return
}
rows, err := queries.GetProductionHasCostDetailHammaddeTypeOptions(ctx, uretimDB, search, limit)
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)
@@ -839,8 +847,14 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht
continue
}
item.Kind = "item"
item.Value = item.SKodu
item.Label = strings.TrimSpace(item.SKodu + " - " + item.SAciklama)
// 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 {
@@ -1237,6 +1251,12 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
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)
@@ -1457,8 +1477,10 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
// 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 {
@@ -1467,13 +1489,31 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
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
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.
@@ -1701,141 +1741,13 @@ WHEN NOT MATCHED THEN
return
}
}
// ============================================================
// Recipe sync (URETIM): ensure recipe contains all OnML hammadde rows
// so future no-cost loads don't keep showing them as missing.
// IMPORTANT: In current URETIM DB, the detail table is dbo.spUrtRecMBolum (NOT NULL cols, FK to dbo.spUrtMBolum).
// We must:
// 1) Skip hammadde types that do not exist in dbo.spUrtMBolum (FK safety),
// 2) Upsert by (nUrtReceteID, nUrtMBolumID, nHStokID_G=sKodu),
// 3) When inserting, generate nUrtRecMBolumID (smallint, not identity) and fill required columns incl. sIslemKodu=''.
// ============================================================
if req.Header.NUrtReceteID > 0 {
receteID := req.Header.NUrtReceteID
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "recipe_sync", "n_urt_recete_id", receteID)
// Determine next available recipe detail id (nUrtRecMBolumID) globally.
// NOTE: nUrtRecMBolumID is smallint and not identity in this schema.
nextRecDetID := 0
_ = tx.QueryRowContext(ctx, `
SELECT ISNULL(MAX(R.nUrtRecMBolumID), 0) + 1
FROM dbo.spUrtRecMBolum R WITH (UPDLOCK, HOLDLOCK)
`).Scan(&nextRecDetID)
if nextRecDetID <= 0 {
nextRecDetID = 1
}
for _, row := range req.Detail.Upserts {
hNo := row.NHammaddeTuruNo
if hNo <= 0 {
continue
}
// Legacy mapping: merge deprecated hammadde types into canonical ones.
// We migrated 1104 -> 1105 historically; keep runtime mapping to avoid FK issues.
if hNo == 1104 {
hNo = 1105
}
// 1. FILTER: CM1/CM2 (Labor/Service) rows must NOT be written back into recipe tables.
// We check the group label (sAciklama3) from the row itself.
g := strings.ToUpper(strings.TrimSpace(row.SAciklama3))
if g == "CM1" || g == "CM2" {
logger.Info("recipe sync skip: labor item", "s_kodu", row.SKodu, "group", g)
continue
}
// FK safety: nUrtMBolumID must exist in dbo.spUrtMBolum.
var bolumExists int
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(1) FROM dbo.spUrtMBolum WITH (NOLOCK)
WHERE nUrtMBolumID = @p1
`, hNo).Scan(&bolumExists); err != nil || bolumExists <= 0 {
logger.Warn("recipe sync skip: missing spUrtMBolum", "n_urt_m_bolum_id", hNo, "s_kodu", strings.TrimSpace(row.SKodu))
continue
}
// Upsert target key: (receteID, hNo, sKodu).
rawSKodu := strings.TrimSpace(row.SKodu)
if rawSKodu == "" {
continue
}
// Update qty if exists.
var exists int
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(1)
FROM dbo.spUrtRecMBolum R WITH (NOLOCK)
WHERE R.nUrtReceteID = @p1
AND R.nUrtMBolumID = @p2
AND LTRIM(RTRIM(R.nHStokID_G)) = @p3
`, receteID, hNo, rawSKodu).Scan(&exists); err == nil && exists > 0 {
_, _ = tx.ExecContext(ctx, `
UPDATE dbo.spUrtRecMBolum
SET lHMiktar_G = @p4
WHERE nUrtReceteID = @p1
AND nUrtMBolumID = @p2
AND LTRIM(RTRIM(nHStokID_G)) = @p3
`, receteID, hNo, rawSKodu, row.LMiktar)
continue
}
// Insert missing into dbo.spUrtRecMBolum.
// nUrtRecMBolumID is not identity; keep incrementing, but guard against smallint overflow.
if nextRecDetID > 32767 {
logger.Warn("recipe sync skip: nUrtRecMBolumID overflow risk", "next_id", nextRecDetID, "n_urt_recete_id", receteID)
continue
}
_, insertErr := 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,
nSure,
sIslemKodu,
lHMiktar_GHedef,
nMBolumSarfTipiNo
)
VALUES (
@p1, -- nUrtRecMBolumID (smallint)
@p2, -- nUrtReceteID
@p3, -- nUrtUBolumID
@p4, -- nUrtMBolumID
0, -- nUrtMTBolumID (tinyint)
1, -- nStokTipiID
@p5, -- nHStokID_G (sKodu)
@p6, -- lHMiktar_G
0, -- lHFire_G
1, -- lHCarpan
6, -- nMaliyetTipiID
0, -- lHMaliyet_G
2, -- nMTalimat_G
0, -- bIslem
0, -- nSure
'', -- sIslemKodu (NOT NULL)
0, -- lHMiktar_GHedef
1 -- nMBolumSarfTipiNo
)
`, nextRecDetID, receteID, 13, hNo, rawSKodu, row.LMiktar)
if insertErr == nil {
nextRecDetID += 1
} else {
logger.Warn("recipe sync insert error", "err", insertErr, "n_urt_recete_id", receteID, "n_urt_m_bolum_id", hNo, "s_kodu", rawSKodu)
}
}
if skippedUpserts > 0 {
logger.Warn("detail upserts skipped summary (det_no<=0)", "trace_id", traceID, "n_onml_no", nOnMLNo, "skipped", skippedUpserts)
}
// NOTE: Recipe tables are intentionally NOT synced from OnML saves.
// This costing screen is the source of truth only for dbo.spUrtOnMLMas / dbo.spUrtOnMLMasDet.
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)
@@ -1942,67 +1854,49 @@ DELETE FROM dbo.spUrtOnMLMas WHERE nOnMLNo = @p1
return
}
// V3: Delete the base price row we created for this costing date (PriceDate = maliyetTarihi).
// 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")
// Primary rule: delete only the row for this exact date and USD currency.
// Safety: require that either CreatedUserName/LastUpdatedUserName matches current user, or one of them starts with BSSAPP.
var createdBy sql.NullString
var lastBy sql.NullString
_ = mssqlDB.QueryRowContext(ctx, `
SELECT TOP 1
ISNULL(CreatedUserName,'') AS CreatedUserName,
ISNULL(LastUpdatedUserName,'') AS LastUpdatedUserName
FROM dbo.prItemBasePrice WITH (NOLOCK)
WHERE ItemTypeCode = 1
AND LTRIM(RTRIM(ItemCode)) = @p1
AND ISNULL(CountryCode,'') = 'TR'
AND ISNULL(SeasonCode,'') = ''
AND ISNULL(BasePriceCode,0) = 1
AND CONVERT(date, PriceDate) = CONVERT(date, @p2, 23)
AND LTRIM(RTRIM(ISNULL(CurrencyCode,''))) = 'USD'
`, urunKodu, priceDate).Scan(&createdBy, &lastBy)
created := strings.ToUpper(strings.TrimSpace(createdBy.String))
last := strings.ToUpper(strings.TrimSpace(lastBy.String))
u := strings.ToUpper(strings.TrimSpace(user))
allowed := false
if u != "" && (strings.ToUpper(strings.TrimSpace(createdBy.String)) == u || strings.ToUpper(strings.TrimSpace(lastBy.String)) == u) {
allowed = true
}
if strings.HasPrefix(created, "BSSAPP") || strings.HasPrefix(last, "BSSAPP") {
allowed = true
}
if allowed {
if _, err := mssqlDB.ExecContext(ctx, `
// 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(CountryCode,'') = 'TR'
AND ISNULL(SeasonCode,'') = ''
AND ISNULL(BasePriceCode,0) = 1
AND CONVERT(date, PriceDate) = CONVERT(date, @p2, 23)
AND LTRIM(RTRIM(ISNULL(CurrencyCode,''))) = 'USD'
`, urunKodu, priceDate); err == nil {
deletedBasePrice = true
} else {
logger.Warn("v3 base price delete failed", "err", err, "urun_kodu", urunKodu, "price_date", priceDate)
}
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 {
logger.Info("v3 base price delete skipped (not owned)", "urun_kodu", urunKodu, "price_date", priceDate, "created_by", createdBy.String, "last_by", lastBy.String, "user", user)
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, "user", user)
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,
"ok": true,
"n_onml_no": req.NOnMLNo,
"urun_kodu": urunKodu,
"deleted_baseprice": deletedBasePrice,
"deleted_baseprice_count": deletedBasePriceCount,
})
}
@@ -3029,12 +2923,51 @@ func GetProductionProductCostingParcaMappingsHandler(w http.ResponseWriter, r *h
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 != "" {
row.NHammaddeTurleri = append(row.NHammaddeTurleri, 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))
}
}
}