From c1c1ed99c754cf6b605f614b1795c2444f2402f5 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Wed, 20 May 2026 20:20:10 +0300 Subject: [PATCH] Merge remote-tracking branch 'origin/master' --- svc/main.go | 5 + svc/queries/production_product_costing.go | 176 +++--- svc/routes/production_product_costing.go | 297 ++++------ svc/routes/production_product_costing_pdf.go | 400 ++++++++++++++ ...g.js.temporary.compiled.1779296132484.mjs} | 0 .../ProductionProductCostingHasCostDetail.vue | 506 +++++++++++++++++- 6 files changed, 1123 insertions(+), 261 deletions(-) create mode 100644 svc/routes/production_product_costing_pdf.go rename ui/{quasar.config.js.temporary.compiled.1778860298326.mjs => quasar.config.js.temporary.compiled.1779296132484.mjs} (100%) diff --git a/svc/main.go b/svc/main.go index 5b4219e..f58d363 100644 --- a/svc/main.go +++ b/svc/main.go @@ -811,6 +811,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "order", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOnMLSaveHandler)), ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/onml/pdf", "GET", + "order", "view", + wrapV3(http.HandlerFunc(routes.GetProductionProductCostingOnMLPDFHandler)), + ) bindV3(r, pgDB, "/api/pricing/production-product-costing/onml/delete", "POST", "order", "view", diff --git a/svc/queries/production_product_costing.go b/svc/queries/production_product_costing.go index be5444d..e022a09 100644 --- a/svc/queries/production_product_costing.go +++ b/svc/queries/production_product_costing.go @@ -912,22 +912,25 @@ func GetProductionHasCostDetailRowsByOnMLNo( nOnMLNo int, ) (*sql.Rows, error) { sqlText := ` -SELECT - ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ') AS sAciklama3, - SUM(ISNULL(D.lTutar, 0)) OVER ( - PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ') - ) AS GroupTotalTutar, - SUM(ISNULL(D.lMiktar, 0) * ISNULL(D.lDovizFiyati, 0)) OVER ( - PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ') - ) AS GroupTotalUSDTutar, + SELECT + -- Prefer the group label stored on the OnML detail row (D.sAciklama3), + -- because some hammadde type master rows may have empty/legacy group labels. + ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ')) AS sAciklama3, + SUM(ISNULL(D.lTutar, 0)) OVER ( + PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ')) + ) AS GroupTotalTutar, + SUM(ISNULL(D.lMiktar, 0) * ISNULL(D.lDovizFiyati, 0)) OVER ( + PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ')) + ) AS GroupTotalUSDTutar, RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nOnMLNo, 0))) AS nOnMLNo, RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nOnMLDetNo, 0))) AS nOnMLDetNo, RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo, - ISNULL(D.sKodu, '') AS sKodu, - ISNULL(D.sAciklama, '') AS sAciklama, - ISNULL(D.sRenk, '') AS sRenk, - ISNULL(D.sBeden, '') AS sBeden, - ISNULL(D.sAciklama2, '') AS sAciklama2, + -- Normalize code to variantless (tbStok.sModel) when D.sKodu is a variant-coded stock record. + ISNULL(NULLIF(LTRIM(RTRIM(SX.sModel)), ''), ISNULL(D.sKodu, '')) AS sKodu, + ISNULL(NULLIF(LTRIM(RTRIM(SX.sAciklama)), ''), ISNULL(D.sAciklama, '')) AS sAciklama, + ISNULL(D.sRenk, '') AS sRenk, + ISNULL(D.sBeden, '') AS sBeden, + ISNULL(D.sAciklama2, '') AS sAciklama2, ISNULL(D.lMiktar, 0) AS lMiktar, ISNULL(D.lFiyat, 0) AS lFiyat, ISNULL(D.lTutar, 0) AS lTutar, @@ -945,15 +948,22 @@ SELECT ISNULL(D.sBirim, '') AS sBirim, ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi, ISNULL(B.sAdi, '') AS sParcaAdi -FROM dbo.spUrtOnMLMasDet D -LEFT JOIN dbo.spUrtOnMLHammaddeTuru T - ON T.nHammaddeTuruNo = D.nHammaddeTuruNo -LEFT JOIN dbo.spUrtMTBolum B - ON B.nUrtMTBolumID = D.nUrtMTBolumID -WHERE D.nOnMLNo = @p1 -ORDER BY - GroupTotalTutar DESC, - sAciklama3 ASC, + FROM dbo.spUrtOnMLMasDet D + LEFT JOIN dbo.spUrtOnMLHammaddeTuru T + ON T.nHammaddeTuruNo = D.nHammaddeTuruNo + LEFT JOIN dbo.spUrtMTBolum B + ON B.nUrtMTBolumID = D.nUrtMTBolumID + OUTER APPLY ( + SELECT TOP 1 + LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) AS sModel, + LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sAciklama, '')))) AS sAciklama + FROM dbo.tbStok S WITH (NOLOCK) + WHERE LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) = LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(D.sKodu, '')))) + ) SX + WHERE D.nOnMLNo = @p1 + ORDER BY + GroupTotalTutar DESC, + sAciklama3 ASC, ISNULL(D.lTutar, 0) DESC, D.nOnMLDetNo ASC ` @@ -1219,31 +1229,45 @@ func GetProductionHasCostDetailHammaddeTypeOptions( uretimDB *sql.DB, search string, limit int, + group string, + onlyActive *bool, ) (*sql.Rows, error) { search = strings.TrimSpace(search) + group = strings.TrimSpace(group) if limit <= 0 { limit = 50 } searchLike := "%" + search + "%" sqlText := ` -SELECT TOP (@p2) - RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo, - ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi, - COALESCE(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(T.sAciklama2)), ''), N'TANIMSIZ') AS sAciklama3, - ISNULL(T.MTnUrtMTBolumID, 0) AS mtUrtMTBolumID, - ISNULL(B.sAdi, '') AS sParcaAdi -FROM dbo.spUrtOnMLHammaddeTuru T WITH (NOLOCK) -LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK) - ON B.nUrtMTBolumID = T.MTnUrtMTBolumID - AND ISNULL(B.nUrtTipiID, 0) = 1 -WHERE - ISNULL(T.bAktif, 0) = 1 - AND ( - @p1 = '' - OR RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) LIKE @p3 - OR ISNULL(T.sAciklama, '') LIKE @p3 - OR ISNULL(T.sAciklama2, '') LIKE @p3 + SELECT TOP (@p2) + RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo, + ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi, + COALESCE(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(T.sAciklama2)), ''), N'TANIMSIZ') AS sAciklama3, + ISNULL(T.MTnUrtMTBolumID, 0) AS mtUrtMTBolumID, + ISNULL(B.sAdi, '') AS sParcaAdi + FROM dbo.spUrtOnMLHammaddeTuru T WITH (NOLOCK) + LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK) + ON B.nUrtMTBolumID = T.MTnUrtMTBolumID + AND ISNULL(B.nUrtTipiID, 0) = 1 + WHERE + -- Active filtering: + -- - By default, list active rows. + -- - If searching by exact number, allow inactive too (legacy CM2 etc.). + -- - If onlyActive flag is provided, honor it explicitly. + ( + (@p5 IS NULL AND (ISNULL(T.bAktif, 0) = 1 OR (@p1 <> '' AND RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) = @p1))) + OR (@p5 IS NOT NULL AND ISNULL(T.bAktif, 0) = @p5) + ) + AND ( + @p4 = '' + OR COALESCE(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(T.sAciklama2)), ''), N'TANIMSIZ') = @p4 + ) + AND ( + @p1 = '' + OR RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) LIKE @p3 + OR ISNULL(T.sAciklama, '') LIKE @p3 + OR ISNULL(T.sAciklama2, '') LIKE @p3 OR ISNULL(T.sAciklama3, '') LIKE @p3 OR ISNULL(B.sAdi, '') LIKE @p3 ) @@ -1256,7 +1280,7 @@ ORDER BY T.nHammaddeTuruNo ` - return uretimDB.QueryContext(ctx, sqlText, search, limit, searchLike) + return uretimDB.QueryContext(ctx, sqlText, search, limit, searchLike, group, onlyActive) } func GetProductionHammaddeByNos(ctx context.Context, uretimDB *sql.DB, nos []int) ([]models.ProductionProductCostingHammaddeByNosItem, error) { @@ -1745,30 +1769,56 @@ FROM dbo.tbStok S`) useFullText := searchLen >= 3 && fullTextSearch != "" && hasProductionHasCostDetailItemFullTextIndex(ctx, uretimDB) baseSelect := ` +WITH c AS ( + SELECT + RTRIM(CONVERT(VARCHAR(32), ISNULL(S.nStokID, 0))) AS nStokID, + LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) AS sKodu, + LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sAciklama, '')))) AS sAciklama, + LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) AS sModel, + LTRIM(RTRIM(CONVERT(NVARCHAR(64), ISNULL(S.sBirimCinsi1, '')))) AS sBirim, + CASE + WHEN LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) = @p1 THEN 0 + WHEN (@p5 = 1 AND S.nStokID = @p6) THEN 1 + WHEN LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) = @p1 THEN 2 + WHEN LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) LIKE @p3 THEN 3 + WHEN LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) LIKE @p3 THEN 4 + ELSE 5 + END AS match_rank, + ROW_NUMBER() OVER ( + PARTITION BY LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) + ORDER BY + CASE + WHEN LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) = @p1 THEN 0 + WHEN (@p5 = 1 AND S.nStokID = @p6) THEN 1 + WHEN LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) = @p1 THEN 2 + WHEN LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) LIKE @p3 THEN 3 + WHEN LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) LIKE @p3 THEN 4 + ELSE 5 + END, + LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) + ) AS rn + FROM dbo.tbStok S + WHERE + (ISNULL(S.IsBlocked, 0) = 0) + AND S.sModel LIKE '_.%%' + AND ( + (@p5 = 1 AND S.nStokID = @p6) + OR S.sModel = @p1 + OR S.sModel LIKE @p3 + OR S.sKodu = @p1 + OR S.sKodu LIKE @p3 + OR %s + ) +) SELECT TOP (@p2) - RTRIM(CONVERT(VARCHAR(32), ISNULL(S.nStokID, 0))) AS nStokID, - LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) AS sKodu, - LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sAciklama, '')))) AS sAciklama, - LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) AS sModel, - LTRIM(RTRIM(CONVERT(NVARCHAR(64), ISNULL(S.sBirimCinsi1, '')))) AS sBirim -FROM dbo.tbStok S -WHERE - (ISNULL(S.IsBlocked, 0) = 0) - AND S.sModel LIKE '_.%%' - AND ( - (@p5 = 1 AND S.nStokID = @p6) - OR S.sKodu = @p1 - OR S.sKodu LIKE @p3 - OR %s - ) -ORDER BY - CASE - WHEN S.sKodu = @p1 THEN 0 - WHEN (@p5 = 1 AND S.nStokID = @p6) THEN 1 - WHEN S.sKodu LIKE @p3 THEN 2 - ELSE 3 - END, - S.sKodu + nStokID, + sKodu, + sAciklama, + sModel, + sBirim +FROM c +WHERE rn = 1 +ORDER BY match_rank, sModel, sKodu OPTION (RECOMPILE) ` diff --git a/svc/routes/production_product_costing.go b/svc/routes/production_product_costing.go index 5083d27..872f2f8 100644 --- a/svc/routes/production_product_costing.go +++ b/svc/routes/production_product_costing.go @@ -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)) } } } diff --git a/svc/routes/production_product_costing_pdf.go b/svc/routes/production_product_costing_pdf.go new file mode 100644 index 0000000..349bbf9 --- /dev/null +++ b/svc/routes/production_product_costing_pdf.go @@ -0,0 +1,400 @@ +package routes + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "net/http" + "strings" + + "bssapp-backend/db" + "bssapp-backend/models" + "bssapp-backend/queries" + "bssapp-backend/utils" + + "github.com/jung-kurt/gofpdf" +) + +// GET /api/pricing/production-product-costing/onml/pdf?n_onml_no=100001 +// Generates a PDF export for the costing detail screen (has-cost). +func GetProductionProductCostingOnMLPDFHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + + nOnMLNo := parsePositiveIntOrDefault(r.URL.Query().Get("n_onml_no"), 0) + if nOnMLNo <= 0 { + http.Error(w, "n_onml_no zorunlu", http.StatusBadRequest) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.onml.pdf", "n_onml_no", nOnMLNo) + logger.Info("request start") + + // Header + hRow, err := queries.GetProductionHasCostDetailHeaderByOnMLNo(ctx, uretimDB, nOnMLNo) + if err != nil { + logger.Error("header query prepare error", "err", err) + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + var header models.ProductionHasCostDetailHeader + if err := hRow.Scan( + &header.UretimiYapanFirma, + &header.SonIsEmriVeren, + &header.FirmaKodu, + &header.NFirmaID, + &header.NOnMLNo, + &header.UrunKodu, + &header.UrunAdi, + &header.UretimSekliID, + &header.UretimSekli, + &header.DteKayitTarihi, + &header.SKullaniciAdi, + &header.LTutarTL, + &header.LTutarUSD, + &header.LTutarEURO, + &header.LTutarGBP, + &header.SDovizCinsi, + &header.LTutarDoviz, + &header.DteGuncellemeTarihi, + &header.SGuncellemeKullaniciAdi, + &header.NUrtReceteID, + ); err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Kayit bulunamadi", http.StatusNotFound) + return + } + logger.Error("header scan error", "err", err) + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + + // Detail groups + groups, err := loadHasCostDetailGroups(ctx, uretimDB, nOnMLNo) + if err != nil { + logger.Error("groups load error", "err", err) + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + + pdf := gofpdf.New("L", "mm", "A4", "") + pdf.SetMargins(8, 8, 8) + pdf.SetAutoPageBreak(false, 10) + if err := registerDejavuFonts(pdf, "dejavu"); err != nil { + http.Error(w, "pdf font error: "+err.Error(), http.StatusInternalServerError) + return + } + + export := &costingPDF{ + pdf: pdf, + header: header, + groups: groups, + } + export.draw() + + if err := pdf.Error(); err != nil { + http.Error(w, "pdf render error: "+err.Error(), http.StatusInternalServerError) + return + } + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + http.Error(w, "pdf output error: "+err.Error(), http.StatusInternalServerError) + return + } + + filename := fmt.Sprintf("onml-%d.pdf", nOnMLNo) + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf.Bytes()) + logger.Info("request done") +} + +func loadHasCostDetailGroups(ctx context.Context, uretimDB *sql.DB, nOnMLNo int) ([]models.ProductionHasCostDetailGroup, error) { + rows, err := queries.GetProductionHasCostDetailRowsByOnMLNo(ctx, uretimDB, nOnMLNo) + if err != nil { + return nil, err + } + defer rows.Close() + + groups := make([]models.ProductionHasCostDetailGroup, 0, 16) + groupIndexByName := map[string]int{} + + for rows.Next() { + var ( + groupName string + groupTotal float64 + groupTotalUSD float64 + nOnMLNoStr string + nOnMLDetNoStr string + hNoStr 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, + &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 { + continue + } + + item.NOnMLNo = strings.TrimSpace(nOnMLNoStr) + item.NOnMLDetNo = strings.TrimSpace(nOnMLDetNoStr) + item.NHammaddeTuruNo = strings.TrimSpace(hNoStr) + 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 { + v := int(cmPriceTypeID.Int64) + item.CMPriceTypeID = &v + } + + 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 { + return nil, err + } + return groups, nil +} + +// --- PDF drawing --- + +type costingPDF struct { + pdf *gofpdf.Fpdf + header models.ProductionHasCostDetailHeader + groups []models.ProductionHasCostDetailGroup +} + +func (c *costingPDF) draw() { + c.addPage(true) + for gi, g := range c.groups { + c.drawGroup(g, gi == 0) + } +} + +func (c *costingPDF) addPage(fullHeader bool) { + c.pdf.AddPage() + if fullHeader { + c.drawHeaderFull() + } else { + c.drawHeaderCompact() + } +} + +func (c *costingPDF) drawHeaderFull() { + pdf := c.pdf + pdf.SetFont("dejavu", "B", 14) + pdf.CellFormat(0, 7, "Maliyet Detay", "", 1, "L", false, 0, "") + + pdf.SetFont("dejavu", "", 9) + pdf.SetTextColor(60, 60, 60) + line1 := fmt.Sprintf("OnML No: %s | Tarih: %s | Uretim Sekli: %s", c.header.NOnMLNo, c.header.DteKayitTarihi, strings.TrimSpace(c.header.UretimSekli)) + pdf.CellFormat(0, 5, line1, "", 1, "L", false, 0, "") + + line2 := fmt.Sprintf("Urun: %s - %s", strings.TrimSpace(c.header.UrunKodu), strings.TrimSpace(c.header.UrunAdi)) + pdf.CellFormat(0, 5, line2, "", 1, "L", false, 0, "") + + line3 := fmt.Sprintf("Firma: %s | Kaydeden: %s | Guncelleme: %s (%s)", strings.TrimSpace(c.header.FirmaKodu), strings.TrimSpace(c.header.SKullaniciAdi), strings.TrimSpace(c.header.DteGuncellemeTarihi), strings.TrimSpace(c.header.SGuncellemeKullaniciAdi)) + pdf.CellFormat(0, 5, line3, "", 1, "L", false, 0, "") + + pdf.SetTextColor(0, 0, 0) + pdf.Ln(2) +} + +func (c *costingPDF) drawHeaderCompact() { + pdf := c.pdf + pdf.SetFont("dejavu", "B", 10.5) + title := fmt.Sprintf("OnML %s | %s - %s | %s", c.header.NOnMLNo, strings.TrimSpace(c.header.UrunKodu), strings.TrimSpace(c.header.UrunAdi), c.header.DteKayitTarihi) + pdf.CellFormat(0, 6, title, "", 1, "L", false, 0, "") + pdf.Ln(1) +} + +func (c *costingPDF) drawGroup(g models.ProductionHasCostDetailGroup, firstGroup bool) { + pdf := c.pdf + + // Group bar + c.drawGroupBar(g, false) + + // Columns + cols := []string{"No", "Parca", "Hammadde", "Kod", "Aciklama", "Renk", "Miktar", "Br", "Fiyat", "Pr.Br", "Tutar(TRY)"} + wn := []float64{10, 24, 24, 40, 90, 18, 18, 12, 20, 14, 24} // sum ~294 (A4 landscape width minus margins) + + c.drawTableHeader(cols, wn) + for _, it := range g.Items { + c.drawRowWithGroup(it, wn, cols, g) + } + pdf.Ln(2) + _ = firstGroup +} + +func (c *costingPDF) drawGroupBar(g models.ProductionHasCostDetailGroup, continued bool) { + pdf := c.pdf + pdf.SetFont("dejavu", "B", 10) + pdf.SetFillColor(245, 245, 245) + name := strings.TrimSpace(g.SAciklama3) + if continued { + name = name + " (devam)" + } + pdf.CellFormat(0, 6, fmt.Sprintf("%s | Toplam TRY: %s | Toplam USD: %s", name, pdfMoney(g.TotalTutar), pdfMoney(g.TotalUSDTutar)), "1", 1, "L", true, 0, "") +} + +func (c *costingPDF) drawTableHeader(cols []string, wn []float64) { + pdf := c.pdf + pdf.SetFont("dejavu", "B", 8) + pdf.SetFillColor(30, 30, 30) + pdf.SetTextColor(255, 255, 255) + for i, col := range cols { + pdf.CellFormat(wn[i], 5.5, col, "1", 0, "C", true, 0, "") + } + pdf.Ln(5.5) + pdf.SetTextColor(0, 0, 0) +} + +func (c *costingPDF) ensureSpace(h float64) (newPage bool) { + pdf := c.pdf + _, pageH := pdf.GetPageSize() + _, _, _, mb := pdf.GetMargins() + if pdf.GetY()+h > pageH-mb { + c.addPage(false) + return true + } + return false +} + +func (c *costingPDF) drawRowWithGroup(it models.ProductionHasCostDetailGroupItem, wn []float64, cols []string, g models.ProductionHasCostDetailGroup) { + pdf := c.pdf + pdf.SetFont("dejavu", "", 7.2) + + // Compute row height based on description wrapping. + descLines := pdf.SplitLines([]byte(strings.TrimSpace(it.SAciklama)), wn[4]-2) + rowH := float64(len(descLines)) * 3.5 + if rowH < 5.0 { + rowH = 5.0 + } + if c.ensureSpace(rowH + 12.0) { + // Redraw group bar + table header on new page. + c.drawGroupBar(g, true) + c.drawTableHeader(cols, wn) + } + + x0 := pdf.GetX() + y0 := pdf.GetY() + + c.drawCell(x0, y0, wn[0], rowH, it.NOnMLDetNo, "R") + x := x0 + wn[0] + c.drawCell(x, y0, wn[1], rowH, strings.TrimSpace(it.SParcaAdi), "L") + x += wn[1] + hLabel := strings.TrimSpace(it.NHammaddeTuruNo) + if strings.TrimSpace(it.SHammaddeTuruAdi) != "" { + hLabel = hLabel + " " + strings.TrimSpace(it.SHammaddeTuruAdi) + } + c.drawCell(x, y0, wn[2], rowH, hLabel, "L") + x += wn[2] + c.drawCell(x, y0, wn[3], rowH, strings.TrimSpace(it.SKodu), "L") + x += wn[3] + c.drawCellWrap(x, y0, wn[4], rowH, strings.TrimSpace(it.SAciklama), "L") + x += wn[4] + c.drawCell(x, y0, wn[5], rowH, strings.TrimSpace(it.SRenk), "L") + x += wn[5] + c.drawCell(x, y0, wn[6], rowH, pdfQty(it.LMiktar), "R") + x += wn[6] + c.drawCell(x, y0, wn[7], rowH, strings.TrimSpace(it.SBirim), "C") + x += wn[7] + + // Prefer input price if present; otherwise lFiyat. + price := it.LFiyat + cur := strings.TrimSpace(it.SDovizCinsi) + if it.FiyatGirilen != nil && *it.FiyatGirilen > 0 { + price = *it.FiyatGirilen + if strings.TrimSpace(it.FiyatDoviz) != "" { + cur = strings.TrimSpace(it.FiyatDoviz) + } + } + c.drawCell(x, y0, wn[8], rowH, pdfMoney(price), "R") + x += wn[8] + c.drawCell(x, y0, wn[9], rowH, cur, "C") + x += wn[9] + c.drawCell(x, y0, wn[10], rowH, pdfMoney(it.LTutar), "R") + + pdf.SetXY(x0, y0+rowH) +} + +func (c *costingPDF) drawCell(x, y, w, h float64, txt, align string) { + pdf := c.pdf + pdf.Rect(x, y, w, h, "") + pdf.SetXY(x+0.8, y+(h-3.5)/2) + pdf.CellFormat(w-1.6, 3.5, txt, "", 0, align, false, 0, "") +} + +func (c *costingPDF) drawCellWrap(x, y, w, h float64, txt, align string) { + pdf := c.pdf + pdf.Rect(x, y, w, h, "") + pdf.SetXY(x+0.8, y+0.6) + pdf.MultiCell(w-1.6, 3.5, txt, "", align, false) + // restore cursor (MultiCell moves Y) + pdf.SetXY(x+w, y) +} + +func pdfMoney(v float64) string { + // 2 decimals, dot + return fmt.Sprintf("%.2f", v) +} + +func pdfQty(v float64) string { + // 4 decimals (screen-like) + return fmt.Sprintf("%.4f", v) +} diff --git a/ui/quasar.config.js.temporary.compiled.1778860298326.mjs b/ui/quasar.config.js.temporary.compiled.1779296132484.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1778860298326.mjs rename to ui/quasar.config.js.temporary.compiled.1779296132484.mjs diff --git a/ui/src/pages/ProductionProductCostingHasCostDetail.vue b/ui/src/pages/ProductionProductCostingHasCostDetail.vue index 99704ac..a09cea8 100644 --- a/ui/src/pages/ProductionProductCostingHasCostDetail.vue +++ b/ui/src/pages/ProductionProductCostingHasCostDetail.vue @@ -84,6 +84,16 @@ :disable="!detailHeader || detailLoading || saveLoading || bulkPriceLoading" @click="saveChanges" /> +
+ + Toplam Miktar: {{ formatBarQuantity(resolveGroupQuantity(grp)) }} MT | + Grup Toplami TRY: {{ formatBarMoney(resolveGroupTRYTutar(grp)) }} | USD: {{ formatBarMoney(resolveGroupUSDTutar(grp)) }} +
+ +
+ + + + +
Kumastan Kopyala
+ +
+ + + + + + + + + +
+
+ @@ -744,7 +802,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useQuasar } from 'quasar' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { usePermission } from 'src/composables/usePermission' -import { get, post, extractApiErrorDetail } from 'src/services/api' +import { get, post, download, extractApiErrorDetail } from 'src/services/api' import { createTraceId, slog } from 'src/utils/slog' const route = useRoute() @@ -773,6 +831,8 @@ const rowEditorDialogOpen = ref(false) const rowEditorMode = ref('new') const rowEditorTargetRowKey = ref('') const rowEditorForm = ref(createRowEditorForm()) +const fabricCopyDialogOpen = ref(false) +const fabricCopySelectedKey = ref('') // Draggable "Satir Duzenle" dialog (mouse drag by header). const rowEditorDialogPos = ref({ x: 0, y: 0 }) @@ -872,10 +932,53 @@ const priceCurrencyOptions = [ ] const flatDetailRows = computed(() => detailGroups.value.flatMap(grp => Array.isArray(grp?.items) ? grp.items : [])) +const showFabricCopyBtn = computed(() => normalizeGroupName(rowEditorForm.value?.sAciklama3 || '') === 'FABRIC') + +const fabricCopySourceRows = computed(() => { + const currentKey = String(rowEditorTargetRowKey.value || '').trim() + return (Array.isArray(flatDetailRows.value) ? flatDetailRows.value : []) + .filter(r => normalizeGroupName(r?.sAciklama3 || '') === 'FABRIC') + .filter(r => String(r?.__rowKey || '').trim() && String(r?.__rowKey || '').trim() !== currentKey) +}) + +const fabricCopyOptions = computed(() => { + // Deduplicate for UX: group by (code + color + hammaddeNo). Keep most recently changed first. + const list = (Array.isArray(fabricCopySourceRows.value) ? fabricCopySourceRows.value : []) + .slice() + .sort((a, b) => { + const da = new Date(a?.dteIslemTarihiDeg || a?.dteIslemTarihi || 0).getTime() + const db = new Date(b?.dteIslemTarihiDeg || b?.dteIslemTarihi || 0).getTime() + return db - da + }) + + const seen = new Set() + const out = [] + for (const r of list) { + const code = String(r?.sKodu || '').trim() + if (!code) continue + const color = String(resolveRowColorCode(r) || '').trim() + const hNo = String(r?.nHammaddeTuruNo || '').trim() + const hName = String(r?.sHammaddeTuruAdi || '').trim() + const key = `${hNo}|${code}|${color}` + if (seen.has(key)) continue + seen.add(key) + const price = resolveNumericRowInputPrice(r) + const cur = resolveInputCurrency(r) + const desc = String(r?.sAciklama || '').trim() + const hLabel = `${hNo}${hName ? ` - ${hName}` : ''}` + const label = `${hLabel} | ${code}${desc ? ` - ${desc}` : ''}${color ? ` | ${color}` : ''}${price > 0 ? ` | ${price} ${cur}` : ''}` + out.push({ value: key, label }) + } + return out +}) + // no-cost: required parca slots (from Maliyet Parca Eslestirme) const requiredParcaMappings = ref([]) const requiredAttentionRowKeys = ref({}) const requiredHammaddeMetaCache = ref({}) // hammaddeNo -> { groupName, parcaAdi } +// When user explicitly deletes a requiredPlaceholder row, we should not keep warning/recreating it +// for this draft/session. Key format: `${mtBolumID}:${hNo}`. +const suppressedRequiredKeys = ref({}) // Bump this when draft payload semantics change, to avoid hydrating stale rows after backend logic updates. const DRAFT_STORAGE_VERSION = 'v2' @@ -929,6 +1032,7 @@ function persistLocalDraftNow () { UretimSekli: String(detailHeader.value?.UretimSekli || '').trim() } : null, deletedDetailRows: Array.isArray(deletedDetailRows.value) ? deletedDetailRows.value : [], + suppressedRequiredKeys: suppressedRequiredKeys.value || {}, detailGroups: Array.isArray(detailGroups.value) ? detailGroups.value : [] } localStorage.setItem(key, JSON.stringify(payload)) @@ -972,6 +1076,7 @@ function tryHydrateFromLocalDraft () { const payload = JSON.parse(raw) if (!payload || typeof payload !== 'object') return false if (Array.isArray(payload.deletedDetailRows)) deletedDetailRows.value = payload.deletedDetailRows + if (payload.suppressedRequiredKeys && typeof payload.suppressedRequiredKeys === 'object') suppressedRequiredKeys.value = payload.suppressedRequiredKeys if (Array.isArray(payload.detailGroups)) detailGroups.value = normalizeDetailGroups(payload.detailGroups) if (payload.header && detailHeader.value) { detailHeader.value.UretimSekliID = String(payload.header.UretimSekliID || '').trim() @@ -1189,6 +1294,11 @@ function formatBarMoney (value) { return formatMoney(roundedValue) } +function formatBarQuantity (value) { + const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000 + return formatQuantity(roundedValue) +} + function normalizePriceCurrency (value) { const normalizedValue = String(value || '').trim().toUpperCase() return ['USD', 'TRY', 'EUR', 'GBP'].includes(normalizedValue) ? normalizedValue : '' @@ -2091,6 +2201,11 @@ function resolveGroupUSDTutar (grp) { return items.reduce((acc, row) => acc + (shouldIncludeRowInGroupTotal(grp, row) ? resolveRowUSDTutar(row) : 0), 0) } +function resolveGroupQuantity (grp) { + const items = Array.isArray(grp?.items) ? grp.items : [] + return items.reduce((acc, row) => acc + (Number(row?.lMiktar || 0) || 0), 0) +} + function groupKey (grp, gi) { return `${String(grp?.sAciklama3 || 'TANIMSIZ')}-${gi}` } @@ -2264,15 +2379,20 @@ async function fetchDetail (options = {}) { if (hydrateDraft) { tryHydrateFromLocalDraft() } - // ensure required placeholder rows exist (based on mapping screen) - try { - const mappings = await fetchRequiredParcaMappings() - await ensureNoCostRequiredRowsFromMappings(mappings) - } catch (err) { - slog.error('production-product-costing.detail', 'required-mapping:error', { - trace_id: traceId.value, - detail: await extractApiErrorDetail(err) - }) + // Ensure required placeholder rows exist (based on mapping screen) only for the "new/no-cost" flow. + // For has-cost records (existing), we do not inject placeholders after load. + // Placeholders should be created only when detail_source=no-cost (route query), + // so saved records always render exactly what backend has. + if (isNoCostDetail.value) { + try { + const mappings = await fetchRequiredParcaMappings() + await ensureNoCostRequiredRowsFromMappings(mappings) + } catch (err) { + slog.error('production-product-costing.detail', 'required-mapping:error', { + trace_id: traceId.value, + detail: await extractApiErrorDetail(err) + }) + } } const initialOpen = {} detailGroups.value.forEach((grp, gi) => { @@ -2821,6 +2941,44 @@ function onRowEditorHammaddeChange (value) { } } +function openFabricCopyDialog () { + fabricCopySelectedKey.value = '' + fabricCopyDialogOpen.value = true +} + +async function applyFabricCopySelection () { + const key = String(fabricCopySelectedKey.value || '').trim() + if (!key) return + const match = fabricCopySourceRows.value.find(r => { + const code = String(r?.sKodu || '').trim() + const color = String(resolveRowColorCode(r) || '').trim() + const hNo = String(r?.nHammaddeTuruNo || '').trim() + return `${hNo}|${code}|${color}` === key + }) + if (!match) { + fabricCopyDialogOpen.value = false + return + } + + // Copy: Code + Price + Currency. Leave color as-is (user may be copying price only). + rowEditorForm.value.sKodu = String(match.sKodu || '').trim() + // Ensure select can render the chosen value even if it isn't in the current options list. + upsertEditorOption(rowEditorItemAllOptions, buildRowEditorItemOption({ ...match, value: String(match.sKodu || '').trim() })) + upsertEditorOption(rowEditorItemOptions, buildRowEditorItemOption({ ...match, value: String(match.sKodu || '').trim() })) + try { + await onRowEditorItemChange(String(match.sKodu || '').trim()) + } catch {} + + const price = resolveNumericRowInputPrice(match) + const cur = resolveInputCurrency(match) || 'USD' + rowEditorForm.value.inputPrice = normalizeInputPrice(price) + rowEditorForm.value.fiyat_girilen = parseMoneyInput(price) + rowEditorForm.value.inputPricePrBr = normalizePriceCurrency(cur) || 'USD' + rowEditorForm.value.fiyat_doviz = normalizePriceCurrency(cur) || 'USD' + + fabricCopyDialogOpen.value = false +} + async function onRowEditorItemChange (value) { const selected = rowEditorItemOptions.value.find(opt => String(opt?.value || '') === String(value || '')) rowEditorForm.value.sKodu = String(value || '').trim() @@ -2980,8 +3138,9 @@ async function fetchRequiredParcaMappings () { async function resolveHammaddeMetaForRequired (hNo) { const key = String(hNo || '').trim() if (!key) return null + // Always attempt a fresh resolve; backend rules for active/inactive hammadde types can change. + // We still store into cache as a last-known-good for the current page load. const cached = requiredHammaddeMetaCache.value?.[key] - if (cached) return cached try { const rows = await get('/pricing/production-product-costing/detail-editor-options', { @@ -3009,7 +3168,7 @@ async function resolveHammaddeMetaForRequired (hNo) { return meta } catch { requiredHammaddeMetaCache.value = { ...(requiredHammaddeMetaCache.value || {}), [key]: { groupName: '', hammaddeAdi: '', mtBolumID: 0, parcaAdi: '' } } - return null + return cached || null } } @@ -3067,6 +3226,7 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) { const hNo = normalizeHammaddeNo(hNoRaw) if (!hNo) continue const reqKey = `${mappingMtBolumID}:${hNo}` + if (suppressedRequiredKeys.value?.[reqKey]) continue if (processedRequiredKeys.has(reqKey)) continue processedRequiredKeys.add(reqKey) @@ -3093,12 +3253,19 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) { if (anyMatch) { // If we previously created a placeholder under a wrong group name, move it instead of duplicating. if (anyMatch?.requiredPlaceholder) { + const ensuredDetNo = String(anyMatch?.nOnMLDetNo || '').trim() || getNextDetailNo() const currentGroup = normalizeGroupName(anyMatch?.sAciklama3) const currentParcaAdi = normalizeGroupName(anyMatch?.sParcaAdi || '') const currentHammaddeAdi = String(anyMatch?.sHammaddeTuruAdi || '').trim() - if (currentGroup !== effectiveGroupName || currentParcaAdi !== desiredParcaAdi || (hammaddeAdi && currentHammaddeAdi !== hammaddeAdi)) { + if ( + String(anyMatch?.nOnMLDetNo || '').trim() !== ensuredDetNo || + currentGroup !== effectiveGroupName || + currentParcaAdi !== desiredParcaAdi || + (hammaddeAdi && currentHammaddeAdi !== hammaddeAdi) + ) { const moved = recalculateDetailRow({ ...anyMatch, + nOnMLDetNo: ensuredDetNo, sAciklama3: effectiveGroupName, sParcaAdi: desiredParcaAdi, nUrtMTBolumID: mtBolumID || anyMatch?.nUrtMTBolumID || anyMatch?.NUrtMTBolumID || 0, @@ -3124,7 +3291,8 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) { isNew: true, nUrtMTBolumID: mtBolumID, nOnMLNo: detailHeader.value?.nOnMLNo || onMLNo.value || '', - nOnMLDetNo: '', + // IMPORTANT: Backend skips upserts where nOnMLDetNo <= 0. Required placeholders must have a real number. + nOnMLDetNo: getNextDetailNo(), // Group header key sAciklama3: effectiveGroupName, // Parca adi column value (CEKET/PANTOLON/YELEK...) @@ -3162,6 +3330,142 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) { } } + // CM2 auto-required: include all active CM2 hammadde types (bAktif=1) as required placeholders too. + // This is independent of the mapping screen, because CM2 labor lines are managed in OnML detail. + try { + // Filter CM2 types by the parts that exist for this product (MTBolumIDs coming from mapping list). + // This prevents irrelevant CM2 types (e.g. GML/KBN/MNT) from showing up for products that don't have those parts. + const allowedMtBolumIDs = new Set() + for (const m of list) { + const mt = parseInt(String(m?.nUrtMTBolumID ?? m?.NUrtMTBolumID ?? '0'), 10) || 0 + if (mt > 0) allowedMtBolumIDs.add(mt) + } + if (allowedMtBolumIDs.size === 0) { + // No parts to scope CM2 by -> don't add global CM2 placeholders. + return + } + + const cm2Types = await get('/pricing/production-product-costing/detail-editor-options', { + kind: 'hammadde', + group: 'CM2', + only_active: 1, + search: '', + limit: 200, + trace_id: traceId.value + }) + const cm2List = Array.isArray(cm2Types) ? cm2Types : [] + for (const it of cm2List) { + const hNo = normalizeHammaddeNo(it?.nHammaddeTuruNo ?? it?.value) + if (!hNo) continue + const meta = { + groupName: String(it?.sAciklama3 || it?.sAciklama2 || 'CM2').trim(), + hammaddeAdi: String(it?.sHammaddeTuruAdi || it?.sAciklama || '').trim(), + mtBolumID: parseInt(String(it?.mtUrtMTBolumID ?? it?.MTUrtMTBolumID ?? it?.nUrtMTBolumID ?? it?.NUrtMTBolumID ?? '0'), 10) || 0, + parcaAdi: String(it?.sParcaAdi || '').trim() + } + if (!(meta.mtBolumID > 0) || !allowedMtBolumIDs.has(meta.mtBolumID)) continue + const effectiveGroupName = normalizeGroupName(meta.groupName || 'CM2') || 'CM2' + const mtBolumID = meta.mtBolumID || 0 + const desiredParcaAdi = normalizeGroupName(meta.parcaAdi || '') || 'CM2' + const reqKey = `${mtBolumID}:${hNo}` + if (mtBolumID > 0 && suppressedRequiredKeys.value?.[reqKey]) continue + + const anyMatch = flatDetailRows.value.find(r => { + if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false + const rowMtBolumID = parseInt(String(r?.nUrtMTBolumID ?? r?.NUrtMTBolumID ?? '0'), 10) || 0 + if (rowMtBolumID > 0 && mtBolumID > 0) return rowMtBolumID === mtBolumID + return normalizeGroupName(r?.sParcaAdi) === desiredParcaAdi + }) + if (anyMatch) { + // Ensure existing required CM2 placeholder has a valid detNo, otherwise backend will skip it on save. + if (anyMatch?.requiredPlaceholder && String(anyMatch?.nOnMLDetNo || '').trim() === '') { + const patched = recalculateDetailRow({ + ...anyMatch, + nOnMLDetNo: getNextDetailNo(), + draftChanged: true + }, { + preserveInputs: true, + priceType: 'REQ', + updateState: 'required-cm2', + markChanged: true + }) + applyEditorRowToGroups(patched) + } + continue + } + + newRowSequence.value += 1 + const rowKey = `req-auto-cm2-row-${newRowSequence.value}` + const placeholder = recalculateDetailRow({ + __rowKey: rowKey, + isNew: true, + nUrtMTBolumID: mtBolumID, + nOnMLNo: detailHeader.value?.nOnMLNo || onMLNo.value || '', + // IMPORTANT: Backend skips upserts where nOnMLDetNo <= 0. Required placeholders must have a real number. + nOnMLDetNo: getNextDetailNo(), + sAciklama3: effectiveGroupName, + sParcaAdi: desiredParcaAdi, + nHammaddeTuruNo: hNo, + sHammaddeTuruAdi: meta.hammaddeAdi, + sKodu: '', + sAciklama: '', + sRenk: '', + ColorCode: '', + ColorDescription: '', + lMiktar: 1, + miktarInput: '1', + inputPrice: '0', + inputPricePrBr: 'USD', + fiyat_girilen: 0, + fiyat_doviz: 'USD', + maliyeteDahil: true, + maliyete_dahil: 1, + Maliyete_dahil: 1, + cmPriceTypeId: normalizeCMPriceTypeId(1, effectiveGroupName), + cm_price_type_id: normalizeCMPriceTypeId(1, effectiveGroupName), + sBirim: 'AD', + draftChanged: true, + requiredPlaceholder: true + }, { + preserveInputs: true, + priceType: 'REQ', + updateState: 'required-cm2', + markChanged: true + }) + applyEditorRowToGroups(placeholder) + } + } catch (e) { + slog.error('production-product-costing.detail', 'cm2:required:error', { + trace_id: traceId.value, + error: String(e?.message || e) + }) + } + + // Debug: required placeholders must always have a valid detNo. + try { + const flatNow = detailGroups.value.flatMap(grp => Array.isArray(grp?.items) ? grp.items : []) + const reqRows = flatNow.filter(r => Boolean(r?.requiredPlaceholder)) + const missingDetNo = reqRows.filter(r => (parseInt(String(r?.nOnMLDetNo || '').trim() || '0', 10) || 0) <= 0) + slog.info('production-product-costing.detail', 'required:placeholders:ensured', { + trace_id: traceId.value, + required_count: reqRows.length, + required_missing_detno: missingDetNo.length, + required_cm2_count: reqRows.filter(r => normalizeGroupName(r?.sAciklama3) === 'CM2').length + }) + if (missingDetNo.length > 0) { + slog.warn('production-product-costing.detail', 'required:placeholders:missing-detno', { + trace_id: traceId.value, + sample: missingDetNo.slice(0, 10).map(r => ({ + detNo: String(r?.nOnMLDetNo || ''), + hNo: String(r?.nHammaddeTuruNo || ''), + mtBolumID: String(r?.nUrtMTBolumID || ''), + group: String(r?.sAciklama3 || ''), + rowKey: String(r?.__rowKey || '') + })) + }) + } + } catch {} + // CM2 special case: // Bulk autofill is disabled for CM2 (and FABRIC) to prevent wrong price pulls, // but we still want the I.* code templates (code + description) to appear on page open. @@ -3243,6 +3547,9 @@ function computeMissingRequiredSlots () { const missing = [] if (list.length === 0) return missing + // Expand suppression keys: support both mappingMtBolumID and meta-derived mtBolumID. + const suppressed = suppressedRequiredKeys.value || {} + list.forEach(mapping => { // Required slots are defined per (ParcaBolum / MTBolumID) + (HammaddeTuruNo). // Do NOT match by row.sAciklama3, because that field is the "group header" (DT/TP/CM2/FABRIC), not the part. @@ -3252,6 +3559,33 @@ function computeMissingRequiredSlots () { hList.forEach(hNoRaw => { const hNo = normalizeHammaddeNo(hNoRaw) if (!hNo) return + const reqKeyByMapping = `${mappingMtBolumID}:${hNo}` + // Also allow suppression by the mtBolumID that the placeholder row itself uses (meta override). + const reqKeyByRowMt = (() => { + const rowLike = flatDetailRows.value.find(r => normalizeHammaddeNo(r?.nHammaddeTuruNo) === hNo && Boolean(r?.requiredPlaceholder)) + const rowMt = parseInt(String(rowLike?.nUrtMTBolumID ?? rowLike?.NUrtMTBolumID ?? '0'), 10) || 0 + return rowMt > 0 ? `${rowMt}:${hNo}` : '' + })() + + // User explicitly removed this required placeholder in the UI; don't keep warning about it. + if (suppressed?.[reqKeyByMapping] || (reqKeyByRowMt && suppressed?.[reqKeyByRowMt])) { + // Auto-clear suppression if a real row exists again. + const existing = flatDetailRows.value.find(r => { + if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false + const rowMtBolumID = parseInt(String(r?.nUrtMTBolumID ?? r?.NUrtMTBolumID ?? '0'), 10) || 0 + if (mappingMtBolumID > 0 && rowMtBolumID > 0) return mappingMtBolumID === rowMtBolumID + return normalizeGroupName(r?.sParcaAdi) === mappingParcaAdi + }) + if (existing) { + const next = { ...(suppressedRequiredKeys.value || {}) } + delete next[reqKeyByMapping] + if (reqKeyByRowMt) delete next[reqKeyByRowMt] + suppressedRequiredKeys.value = next + schedulePersistLocalDraft() + } else { + return + } + } const match = flatDetailRows.value.find(r => { if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false @@ -3294,6 +3628,16 @@ function removeDetailRowByKey (rowKey) { detailGroups.value = nextGroups syncAllGroupsOpen() + // If user deletes an auto-created required placeholder, suppress the corresponding required key + // so we don't keep recreating/warning about it for this draft. + if (existingRow?.requiredPlaceholder) { + const hNo = normalizeHammaddeNo(existingRow?.nHammaddeTuruNo) + const mtBolumID = parseInt(String(existingRow?.nUrtMTBolumID ?? existingRow?.NUrtMTBolumID ?? '0'), 10) || 0 + if (hNo && mtBolumID > 0) { + const reqKey = `${mtBolumID}:${hNo}` + suppressedRequiredKeys.value = { ...(suppressedRequiredKeys.value || {}), [reqKey]: true } + } + } if (existingRow && !existingRow?.isNew) { deletedDetailRows.value = [ ...(Array.isArray(deletedDetailRows.value) ? deletedDetailRows.value : []), @@ -3588,17 +3932,79 @@ async function confirmRefresh () { await fetchDetail({ clearDraft: true, hydrateDraft: false }) } +function scrollToFirstRequiredAttentionRow () { + // Best-effort DOM scroll: q-table renders rowClass on the . + // This avoids users having to hunt for missing rows after we block save. + try { + const el = document.querySelector('.pcd-detail-row-required') + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'center', behavior: 'smooth' }) + } + } catch {} +} + async function saveChanges () { saveLoading.value = true try { requiredAttentionRowKeys.value = {} if (isNoCostDetail.value) { const missing = computeMissingRequiredSlots() - if (missing.length > 0) { + slog.info('production-product-costing.detail', 'required:missing:computed', { + trace_id: traceId.value, + missing_count: missing.length, + sample: missing.slice(0, 20).map(m => ({ + mtBolumID: m?.mtBolumID, + parcaAdi: String(m?.parcaAdi || '').trim(), + hNo: String(m?.nHammaddeTuruNo || '').trim(), + rowKey: String(m?.rowKey || '').trim() + })) + }) + if (missing.length > 0) { + const rowsHtml = missing.slice(0, 40).map((m, i) => { + const key = String(m?.rowKey || '').trim() + const row = key ? flatDetailRows.value.find(r => String(r?.__rowKey || '').trim() === key) : null + const code = String(row?.sKodu || '').trim() + const desc = String(row?.sAciklama || '').trim() + return ` + + ${i + 1} + ${String(m?.parcaAdi || '').trim()} + ${String(m?.nHammaddeTuruNo || '').trim()} + ${code || '-'} + ${desc || '-'} + + ` + }).join('') + const truncatedNote = missing.length > 40 + ? `
Ilk 40 satir gosterildi. Toplam: ${missing.length}
` + : '' const ok = await new Promise(resolve => { $q.dialog({ title: 'Eksik Maliyet Parcalari', - message: `Eslestirilen parcalarda fiyat girilmemis satirlar var. Devam etmek istiyor musunuz? (Eksik: ${missing.length})`, + html: true, + message: ` +
+ Eslestirilen parcalarda fiyat girilmemis satirlar var. Devam etmek istiyor musunuz? +
Eksik: ${missing.length}
+
+ + + + + + + + + + + + ${rowsHtml} + +
NoParcaHammaddeKodAciklama
+
+ ${truncatedNote} +
+ `, cancel: true, persistent: true }).onOk(() => resolve(true)).onCancel(() => resolve(false)) @@ -3614,6 +4020,8 @@ async function saveChanges () { message: 'Eksik parcalar isaretlendi. Fiyat girip tekrar deneyin.', position: 'top-right' }) + // Scroll to first highlighted row. + nextTick(() => scrollToFirstRequiredAttentionRow()) return } } @@ -3627,6 +4035,17 @@ async function saveChanges () { return } + // Validate required header fields + const selectedUretimSekliID = parseInt(String(detailHeader.value?.UretimSekliID || '0').trim() || '0', 10) || 0 + if (!(selectedUretimSekliID > 0)) { + $q.notify({ + type: 'warning', + message: 'Uretim Sekli secilmeden kayit yapilamaz.', + position: 'top-right' + }) + return + } + // Validate: "Kod" (sKodu) is mandatory for any row that has a hammadde type. // We block saving if there are rows with empty/whitespace code, to avoid sending blank rows to backend. const blankCodeRows = (Array.isArray(flatDetailRows.value) ? flatDetailRows.value : []) @@ -3651,6 +4070,24 @@ async function saveChanges () { } const header = detailHeader.value + // Debug: backend skips rows where n_onml_det_no <= 0. Log this before sending. + try { + const rows = Array.isArray(flatDetailRows.value) ? flatDetailRows.value : [] + const detNoZero = rows.filter(r => (parseInt(String(r?.nOnMLDetNo || '').trim() || '0', 10) || 0) <= 0) + slog.info('production-product-costing.detail', 'save:payload:summary', { + trace_id: traceId.value, + row_count: rows.length, + det_no_zero_count: detNoZero.length, + det_no_zero_sample: detNoZero.slice(0, 10).map(r => ({ + rowKey: String(r?.__rowKey || ''), + detNo: String(r?.nOnMLDetNo || ''), + hNo: String(r?.nHammaddeTuruNo || ''), + group: String(r?.sAciklama3 || ''), + code: String(r?.sKodu || '').trim() + })) + }) + } catch {} + const upserts = flatDetailRows.value.map(r => ({ n_onml_det_no: parseInt(String(r?.nOnMLDetNo || '').trim() || '0', 10) || 0, n_hammadde_turu_no: parseInt(String(r?.nHammaddeTuruNo || '').trim() || '0', 10) || 0, @@ -3734,6 +4171,27 @@ async function saveChanges () { } } +async function exportCostingPDF () { + try { + const n = parseInt(String(onMLNo.value || detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || '0'), 10) || 0 + if (!(n > 0)) { + $q.notify({ type: 'warning', message: 'OnML No bulunamadi.', position: 'top-right' }) + return + } + const blob = await download('/pricing/production-product-costing/onml/pdf', { + n_onml_no: n, + trace_id: traceId.value + }) + const url = URL.createObjectURL(blob) + window.open(url, '_blank') + window.setTimeout(() => { + try { URL.revokeObjectURL(url) } catch {} + }, 60_000) + } catch (e) { + $q.notify({ type: 'negative', message: await extractApiErrorDetail(e), position: 'top-right' }) + } +} + watch( () => [onMLNo.value, productCode.value, recipeCode.value, detailSource.value], () => { @@ -4062,6 +4520,22 @@ watch( overflow: visible !important; } +.pcd-detail-table :deep(.q-table) { + table-layout: fixed; + width: 100%; +} + +.pcd-detail-table :deep(.q-table th), +.pcd-detail-table :deep(.q-table td) { + overflow: hidden; + text-overflow: ellipsis; +} + +.pcd-detail-table :deep(.q-table tbody td) { + white-space: normal; + word-break: break-word; +} + .pcd-detail-table :deep(.q-table thead) { display: table-header-group !important; }