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

@@ -811,6 +811,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"order", "view", "order", "view",
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOnMLSaveHandler)), 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, bindV3(r, pgDB,
"/api/pricing/production-product-costing/onml/delete", "POST", "/api/pricing/production-product-costing/onml/delete", "POST",
"order", "view", "order", "view",

View File

@@ -912,19 +912,22 @@ func GetProductionHasCostDetailRowsByOnMLNo(
nOnMLNo int, nOnMLNo int,
) (*sql.Rows, error) { ) (*sql.Rows, error) {
sqlText := ` sqlText := `
SELECT SELECT
ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ') AS sAciklama3, -- 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 ( SUM(ISNULL(D.lTutar, 0)) OVER (
PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ') PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ'))
) AS GroupTotalTutar, ) AS GroupTotalTutar,
SUM(ISNULL(D.lMiktar, 0) * ISNULL(D.lDovizFiyati, 0)) OVER ( SUM(ISNULL(D.lMiktar, 0) * ISNULL(D.lDovizFiyati, 0)) OVER (
PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ') PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ'))
) AS GroupTotalUSDTutar, ) AS GroupTotalUSDTutar,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nOnMLNo, 0))) AS nOnMLNo, 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.nOnMLDetNo, 0))) AS nOnMLDetNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo, RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo,
ISNULL(D.sKodu, '') AS sKodu, -- Normalize code to variantless (tbStok.sModel) when D.sKodu is a variant-coded stock record.
ISNULL(D.sAciklama, '') AS sAciklama, 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.sRenk, '') AS sRenk,
ISNULL(D.sBeden, '') AS sBeden, ISNULL(D.sBeden, '') AS sBeden,
ISNULL(D.sAciklama2, '') AS sAciklama2, ISNULL(D.sAciklama2, '') AS sAciklama2,
@@ -945,13 +948,20 @@ SELECT
ISNULL(D.sBirim, '') AS sBirim, ISNULL(D.sBirim, '') AS sBirim,
ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi, ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi,
ISNULL(B.sAdi, '') AS sParcaAdi ISNULL(B.sAdi, '') AS sParcaAdi
FROM dbo.spUrtOnMLMasDet D FROM dbo.spUrtOnMLMasDet D
LEFT JOIN dbo.spUrtOnMLHammaddeTuru T LEFT JOIN dbo.spUrtOnMLHammaddeTuru T
ON T.nHammaddeTuruNo = D.nHammaddeTuruNo ON T.nHammaddeTuruNo = D.nHammaddeTuruNo
LEFT JOIN dbo.spUrtMTBolum B LEFT JOIN dbo.spUrtMTBolum B
ON B.nUrtMTBolumID = D.nUrtMTBolumID ON B.nUrtMTBolumID = D.nUrtMTBolumID
WHERE D.nOnMLNo = @p1 OUTER APPLY (
ORDER BY 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, GroupTotalTutar DESC,
sAciklama3 ASC, sAciklama3 ASC,
ISNULL(D.lTutar, 0) DESC, ISNULL(D.lTutar, 0) DESC,
@@ -1219,26 +1229,40 @@ func GetProductionHasCostDetailHammaddeTypeOptions(
uretimDB *sql.DB, uretimDB *sql.DB,
search string, search string,
limit int, limit int,
group string,
onlyActive *bool,
) (*sql.Rows, error) { ) (*sql.Rows, error) {
search = strings.TrimSpace(search) search = strings.TrimSpace(search)
group = strings.TrimSpace(group)
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
searchLike := "%" + search + "%" searchLike := "%" + search + "%"
sqlText := ` sqlText := `
SELECT TOP (@p2) SELECT TOP (@p2)
RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo, RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo,
ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi, ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi,
COALESCE(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(T.sAciklama2)), ''), N'TANIMSIZ') AS sAciklama3, COALESCE(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(T.sAciklama2)), ''), N'TANIMSIZ') AS sAciklama3,
ISNULL(T.MTnUrtMTBolumID, 0) AS mtUrtMTBolumID, ISNULL(T.MTnUrtMTBolumID, 0) AS mtUrtMTBolumID,
ISNULL(B.sAdi, '') AS sParcaAdi ISNULL(B.sAdi, '') AS sParcaAdi
FROM dbo.spUrtOnMLHammaddeTuru T WITH (NOLOCK) FROM dbo.spUrtOnMLHammaddeTuru T WITH (NOLOCK)
LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK) LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK)
ON B.nUrtMTBolumID = T.MTnUrtMTBolumID ON B.nUrtMTBolumID = T.MTnUrtMTBolumID
AND ISNULL(B.nUrtTipiID, 0) = 1 AND ISNULL(B.nUrtTipiID, 0) = 1
WHERE WHERE
ISNULL(T.bAktif, 0) = 1 -- 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 ( AND (
@p1 = '' @p1 = ''
OR RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) LIKE @p3 OR RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) LIKE @p3
@@ -1256,7 +1280,7 @@ ORDER BY
T.nHammaddeTuruNo 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) { 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) useFullText := searchLen >= 3 && fullTextSearch != "" && hasProductionHasCostDetailItemFullTextIndex(ctx, uretimDB)
baseSelect := ` baseSelect := `
SELECT TOP (@p2) WITH c AS (
SELECT
RTRIM(CONVERT(VARCHAR(32), ISNULL(S.nStokID, 0))) AS nStokID, 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.sKodu, '')))) AS sKodu,
LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sAciklama, '')))) AS sAciklama, LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sAciklama, '')))) AS sAciklama,
LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) AS sModel, LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) AS sModel,
LTRIM(RTRIM(CONVERT(NVARCHAR(64), ISNULL(S.sBirimCinsi1, '')))) AS sBirim LTRIM(RTRIM(CONVERT(NVARCHAR(64), ISNULL(S.sBirimCinsi1, '')))) AS sBirim,
FROM dbo.tbStok S CASE
WHERE 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) (ISNULL(S.IsBlocked, 0) = 0)
AND S.sModel LIKE '_.%%' AND S.sModel LIKE '_.%%'
AND ( AND (
(@p5 = 1 AND S.nStokID = @p6) (@p5 = 1 AND S.nStokID = @p6)
OR S.sModel = @p1
OR S.sModel LIKE @p3
OR S.sKodu = @p1 OR S.sKodu = @p1
OR S.sKodu LIKE @p3 OR S.sKodu LIKE @p3
OR %s OR %s
) )
ORDER BY )
CASE SELECT TOP (@p2)
WHEN S.sKodu = @p1 THEN 0 nStokID,
WHEN (@p5 = 1 AND S.nStokID = @p6) THEN 1 sKodu,
WHEN S.sKodu LIKE @p3 THEN 2 sAciklama,
ELSE 3 sModel,
END, sBirim
S.sKodu FROM c
WHERE rn = 1
ORDER BY match_rank, sModel, sKodu
OPTION (RECOMPILE) OPTION (RECOMPILE)
` `

View File

@@ -767,7 +767,15 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht
return 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 { if err != nil {
logger.Error("hammadde query error", "err", err) logger.Error("hammadde query error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] hammadde query error: %v", err) log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] hammadde query error: %v", err)
@@ -839,8 +847,14 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht
continue continue
} }
item.Kind = "item" item.Kind = "item"
item.Value = item.SKodu // Use variantless code (tbStok.sModel) as the value for costing/recipe.
item.Label = strings.TrimSpace(item.SKodu + " - " + item.SAciklama) // 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) list = append(list, item)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -1237,6 +1251,12 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
return 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.DetailSource = strings.ToLower(strings.TrimSpace(req.DetailSource))
req.Header.UrunKodu = strings.TrimSpace(req.Header.UrunKodu) req.Header.UrunKodu = strings.TrimSpace(req.Header.UrunKodu)
@@ -1457,8 +1477,10 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
// Deletes // Deletes
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "detail_deletes", "count", len(req.Detail.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 { for _, d := range req.Detail.Deletes {
if d.NOnMLDetNo <= 0 { if d.NOnMLDetNo <= 0 {
skippedDeletes += 1
continue continue
} }
if _, err := tx.ExecContext(ctx, `DELETE FROM dbo.spUrtOnMLMasDet WHERE nOnMLNo=@p1 AND nOnMLDetNo=@p2`, nOnMLNo, d.NOnMLDetNo); err != nil { 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 return
} }
} }
if skippedDeletes > 0 {
logger.Warn("detail deletes skipped (det_no<=0)", "trace_id", traceID, "n_onml_no", nOnMLNo, "skipped", skippedDeletes)
}
// Upserts // Upserts
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "detail_upserts", "count", len(req.Detail.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 { for _, row := range req.Detail.Upserts {
if row.NOnMLDetNo <= 0 { 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 continue
} }
if row.NHammaddeTuruNo <= 0 || strings.TrimSpace(row.SKodu) == "" { if row.NHammaddeTuruNo <= 0 || strings.TrimSpace(row.SKodu) == "" {
// FALLBACK: If nHammaddeTuruNo is missing but sKodu is present, default to 1 (General/Labor) // FALLBACK: If nHammaddeTuruNo is missing but sKodu is present, default to 1 (General/Labor)
// to avoid blocking the user, especially for labor items. // to avoid blocking the user, especially for labor items.
@@ -1701,140 +1741,12 @@ WHEN NOT MATCHED THEN
return return
} }
} }
if skippedUpserts > 0 {
// ============================================================ logger.Warn("detail upserts skipped summary (det_no<=0)", "trace_id", traceID, "n_onml_no", nOnMLNo, "skipped", skippedUpserts)
// 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 { // NOTE: Recipe tables are intentionally NOT synced from OnML saves.
hNo := row.NHammaddeTuruNo // This costing screen is the source of truth only for dbo.spUrtOnMLMas / dbo.spUrtOnMLMasDet.
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)
}
}
}
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "commit") logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "commit")
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
@@ -1942,67 +1854,49 @@ DELETE FROM dbo.spUrtOnMLMas WHERE nOnMLNo = @p1
return 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. // 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 deletedBasePrice := false
deletedBasePriceCount := 0
if mssqlDB != nil && urunKodu != "" { if mssqlDB != nil && urunKodu != "" {
priceDate := maliyetTarihi.Format("2006-01-02") priceDate := maliyetTarihi.Format("2006-01-02")
// Primary rule: delete only the row for this exact date and USD currency. // Delete only rows owned by this app (Created/LastUpdated starts with BSSAPP).
// Safety: require that either CreatedUserName/LastUpdatedUserName matches current user, or one of them starts with BSSAPP. res, err := mssqlDB.ExecContext(ctx, `
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 FROM dbo.prItemBasePrice DELETE FROM dbo.prItemBasePrice
WHERE ItemTypeCode = 1 WHERE ItemTypeCode = 1
AND LTRIM(RTRIM(ItemCode)) = @p1 AND LTRIM(RTRIM(ItemCode)) = @p1
AND ISNULL(CountryCode,'') = 'TR'
AND ISNULL(SeasonCode,'') = '' AND ISNULL(SeasonCode,'') = ''
AND ISNULL(BasePriceCode,0) = 1 AND ISNULL(BasePriceCode,0) = 1
AND CONVERT(date, PriceDate) = CONVERT(date, @p2, 23) AND CONVERT(date, PriceDate) = CONVERT(date, @p2, 23)
AND LTRIM(RTRIM(ISNULL(CurrencyCode,''))) = 'USD' AND LTRIM(RTRIM(ISNULL(CurrencyCode,''))) = 'USD'
`, urunKodu, priceDate); err == nil { AND (
deletedBasePrice = true UPPER(LTRIM(RTRIM(ISNULL(CreatedUserName,'')))) LIKE 'BSSAPP%'
} else { 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) logger.Warn("v3 base price delete failed", "err", err, "urun_kodu", urunKodu, "price_date", priceDate)
}
} else { } 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{ _ = json.NewEncoder(w).Encode(map[string]any{
"ok": true, "ok": true,
"n_onml_no": req.NOnMLNo, "n_onml_no": req.NOnMLNo,
"urun_kodu": urunKodu, "urun_kodu": urunKodu,
"deleted_baseprice": deletedBasePrice, "deleted_baseprice": deletedBasePrice,
"deleted_baseprice_count": deletedBasePriceCount,
}) })
} }
@@ -3029,12 +2923,51 @@ func GetProductionProductCostingParcaMappingsHandler(w http.ResponseWriter, r *h
continue continue
} }
row.BAktif = bAktif.Valid && bAktif.Bool 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) row.NHammaddeTurleri = make([]string, 0, 8)
if hammaddeCsv.Valid { if hammaddeCsv.Valid {
for _, part := range strings.Split(hammaddeCsv.String, ",") { for _, part := range strings.Split(hammaddeCsv.String, ",") {
part = strings.TrimSpace(part) part = strings.TrimSpace(part)
if part != "" { if part != "" {
n, err := strconv.Atoi(part)
if err != nil {
// keep as-is
row.NHammaddeTurleri = append(row.NHammaddeTurleri, part) 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))
} }
} }
} }

View File

@@ -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)
}

View File

@@ -84,6 +84,16 @@
:disable="!detailHeader || detailLoading || saveLoading || bulkPriceLoading" :disable="!detailHeader || detailLoading || saveLoading || bulkPriceLoading"
@click="saveChanges" @click="saveChanges"
/> />
<q-btn
label="PDF"
icon="picture_as_pdf"
dense
color="grey-9"
outline
class="pcd-toolbar-btn"
:disable="!detailHeader || detailLoading || saveLoading"
@click="exportCostingPDF"
/>
<q-btn <q-btn
label="Kaydi Sil" label="Kaydi Sil"
icon="delete" icon="delete"
@@ -244,6 +254,9 @@
{{ grp.sAciklama3 || 'TANIMSIZ' }} {{ grp.sAciklama3 || 'TANIMSIZ' }}
</div> </div>
<div class="sub-right pcd-sub-right-clickable" @click="toggleGroup(grp, gi)"> <div class="sub-right pcd-sub-right-clickable" @click="toggleGroup(grp, gi)">
<span v-if="normalizeGroupName(grp.sAciklama3) === 'FABRIC'" class="q-mr-sm">
Toplam Miktar: {{ formatBarQuantity(resolveGroupQuantity(grp)) }} MT |
</span>
Grup Toplami TRY: {{ formatBarMoney(resolveGroupTRYTutar(grp)) }} | USD: {{ formatBarMoney(resolveGroupUSDTutar(grp)) }} Grup Toplami TRY: {{ formatBarMoney(resolveGroupTRYTutar(grp)) }} | USD: {{ formatBarMoney(resolveGroupUSDTutar(grp)) }}
<q-icon <q-icon
:name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'" :name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'"
@@ -529,6 +542,16 @@
@filter="filterRowEditorItemOptions" @filter="filterRowEditorItemOptions"
@update:model-value="onRowEditorItemChange" @update:model-value="onRowEditorItemChange"
/> />
<div v-if="showFabricCopyBtn" class="q-mt-xs row items-center q-gutter-xs">
<q-btn
dense
outline
color="primary"
icon="content_copy"
label="Kumastan Kopyala"
@click="openFabricCopyDialog"
/>
</div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<q-select <q-select
@@ -599,6 +622,41 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- FABRIC copy helper: pick another fabric row and copy Code + Price + Pr.Br into editor -->
<q-dialog v-model="fabricCopyDialogOpen">
<q-card style="min-width: min(720px, 92vw);">
<q-card-section class="row items-center justify-between q-pb-sm">
<div class="text-subtitle1 text-weight-bold">Kumastan Kopyala</div>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-separator />
<q-card-section class="q-pa-md">
<q-select
v-model="fabricCopySelectedKey"
dense
filled
label="Kaynak Kumaş"
:options="fabricCopyOptions"
option-value="value"
option-label="label"
emit-value
map-options
/>
</q-card-section>
<q-separator />
<q-card-actions align="right" class="q-pa-sm">
<q-btn flat label="Iptal" color="grey-7" v-close-popup />
<q-btn
unelevated
color="primary"
label="Kopyala"
:disable="!fabricCopySelectedKey"
@click="applyFabricCopySelection"
/>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="lineHistoryDialogOpen" maximized> <q-dialog v-model="lineHistoryDialogOpen" maximized>
<q-card class="pcd-history-dialog"> <q-card class="pcd-history-dialog">
<q-card-section class="row items-center justify-between q-gutter-sm"> <q-card-section class="row items-center justify-between q-gutter-sm">
@@ -744,7 +802,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { usePermission } from 'src/composables/usePermission' 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' import { createTraceId, slog } from 'src/utils/slog'
const route = useRoute() const route = useRoute()
@@ -773,6 +831,8 @@ const rowEditorDialogOpen = ref(false)
const rowEditorMode = ref('new') const rowEditorMode = ref('new')
const rowEditorTargetRowKey = ref('') const rowEditorTargetRowKey = ref('')
const rowEditorForm = ref(createRowEditorForm()) const rowEditorForm = ref(createRowEditorForm())
const fabricCopyDialogOpen = ref(false)
const fabricCopySelectedKey = ref('')
// Draggable "Satir Duzenle" dialog (mouse drag by header). // Draggable "Satir Duzenle" dialog (mouse drag by header).
const rowEditorDialogPos = ref({ x: 0, y: 0 }) 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 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) // no-cost: required parca slots (from Maliyet Parca Eslestirme)
const requiredParcaMappings = ref([]) const requiredParcaMappings = ref([])
const requiredAttentionRowKeys = ref({}) const requiredAttentionRowKeys = ref({})
const requiredHammaddeMetaCache = ref({}) // hammaddeNo -> { groupName, parcaAdi } 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. // Bump this when draft payload semantics change, to avoid hydrating stale rows after backend logic updates.
const DRAFT_STORAGE_VERSION = 'v2' const DRAFT_STORAGE_VERSION = 'v2'
@@ -929,6 +1032,7 @@ function persistLocalDraftNow () {
UretimSekli: String(detailHeader.value?.UretimSekli || '').trim() UretimSekli: String(detailHeader.value?.UretimSekli || '').trim()
} : null, } : null,
deletedDetailRows: Array.isArray(deletedDetailRows.value) ? deletedDetailRows.value : [], deletedDetailRows: Array.isArray(deletedDetailRows.value) ? deletedDetailRows.value : [],
suppressedRequiredKeys: suppressedRequiredKeys.value || {},
detailGroups: Array.isArray(detailGroups.value) ? detailGroups.value : [] detailGroups: Array.isArray(detailGroups.value) ? detailGroups.value : []
} }
localStorage.setItem(key, JSON.stringify(payload)) localStorage.setItem(key, JSON.stringify(payload))
@@ -972,6 +1076,7 @@ function tryHydrateFromLocalDraft () {
const payload = JSON.parse(raw) const payload = JSON.parse(raw)
if (!payload || typeof payload !== 'object') return false if (!payload || typeof payload !== 'object') return false
if (Array.isArray(payload.deletedDetailRows)) deletedDetailRows.value = payload.deletedDetailRows 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 (Array.isArray(payload.detailGroups)) detailGroups.value = normalizeDetailGroups(payload.detailGroups)
if (payload.header && detailHeader.value) { if (payload.header && detailHeader.value) {
detailHeader.value.UretimSekliID = String(payload.header.UretimSekliID || '').trim() detailHeader.value.UretimSekliID = String(payload.header.UretimSekliID || '').trim()
@@ -1189,6 +1294,11 @@ function formatBarMoney (value) {
return formatMoney(roundedValue) return formatMoney(roundedValue)
} }
function formatBarQuantity (value) {
const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000
return formatQuantity(roundedValue)
}
function normalizePriceCurrency (value) { function normalizePriceCurrency (value) {
const normalizedValue = String(value || '').trim().toUpperCase() const normalizedValue = String(value || '').trim().toUpperCase()
return ['USD', 'TRY', 'EUR', 'GBP'].includes(normalizedValue) ? normalizedValue : '' 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) 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) { function groupKey (grp, gi) {
return `${String(grp?.sAciklama3 || 'TANIMSIZ')}-${gi}` return `${String(grp?.sAciklama3 || 'TANIMSIZ')}-${gi}`
} }
@@ -2264,7 +2379,11 @@ async function fetchDetail (options = {}) {
if (hydrateDraft) { if (hydrateDraft) {
tryHydrateFromLocalDraft() tryHydrateFromLocalDraft()
} }
// ensure required placeholder rows exist (based on mapping screen) // 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 { try {
const mappings = await fetchRequiredParcaMappings() const mappings = await fetchRequiredParcaMappings()
await ensureNoCostRequiredRowsFromMappings(mappings) await ensureNoCostRequiredRowsFromMappings(mappings)
@@ -2274,6 +2393,7 @@ async function fetchDetail (options = {}) {
detail: await extractApiErrorDetail(err) detail: await extractApiErrorDetail(err)
}) })
} }
}
const initialOpen = {} const initialOpen = {}
detailGroups.value.forEach((grp, gi) => { detailGroups.value.forEach((grp, gi) => {
initialOpen[groupKey(grp, gi)] = true initialOpen[groupKey(grp, gi)] = true
@@ -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) { async function onRowEditorItemChange (value) {
const selected = rowEditorItemOptions.value.find(opt => String(opt?.value || '') === String(value || '')) const selected = rowEditorItemOptions.value.find(opt => String(opt?.value || '') === String(value || ''))
rowEditorForm.value.sKodu = String(value || '').trim() rowEditorForm.value.sKodu = String(value || '').trim()
@@ -2980,8 +3138,9 @@ async function fetchRequiredParcaMappings () {
async function resolveHammaddeMetaForRequired (hNo) { async function resolveHammaddeMetaForRequired (hNo) {
const key = String(hNo || '').trim() const key = String(hNo || '').trim()
if (!key) return null 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] const cached = requiredHammaddeMetaCache.value?.[key]
if (cached) return cached
try { try {
const rows = await get('/pricing/production-product-costing/detail-editor-options', { const rows = await get('/pricing/production-product-costing/detail-editor-options', {
@@ -3009,7 +3168,7 @@ async function resolveHammaddeMetaForRequired (hNo) {
return meta return meta
} catch { } catch {
requiredHammaddeMetaCache.value = { ...(requiredHammaddeMetaCache.value || {}), [key]: { groupName: '', hammaddeAdi: '', mtBolumID: 0, parcaAdi: '' } } 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) const hNo = normalizeHammaddeNo(hNoRaw)
if (!hNo) continue if (!hNo) continue
const reqKey = `${mappingMtBolumID}:${hNo}` const reqKey = `${mappingMtBolumID}:${hNo}`
if (suppressedRequiredKeys.value?.[reqKey]) continue
if (processedRequiredKeys.has(reqKey)) continue if (processedRequiredKeys.has(reqKey)) continue
processedRequiredKeys.add(reqKey) processedRequiredKeys.add(reqKey)
@@ -3093,12 +3253,19 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) {
if (anyMatch) { if (anyMatch) {
// If we previously created a placeholder under a wrong group name, move it instead of duplicating. // If we previously created a placeholder under a wrong group name, move it instead of duplicating.
if (anyMatch?.requiredPlaceholder) { if (anyMatch?.requiredPlaceholder) {
const ensuredDetNo = String(anyMatch?.nOnMLDetNo || '').trim() || getNextDetailNo()
const currentGroup = normalizeGroupName(anyMatch?.sAciklama3) const currentGroup = normalizeGroupName(anyMatch?.sAciklama3)
const currentParcaAdi = normalizeGroupName(anyMatch?.sParcaAdi || '') const currentParcaAdi = normalizeGroupName(anyMatch?.sParcaAdi || '')
const currentHammaddeAdi = String(anyMatch?.sHammaddeTuruAdi || '').trim() 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({ const moved = recalculateDetailRow({
...anyMatch, ...anyMatch,
nOnMLDetNo: ensuredDetNo,
sAciklama3: effectiveGroupName, sAciklama3: effectiveGroupName,
sParcaAdi: desiredParcaAdi, sParcaAdi: desiredParcaAdi,
nUrtMTBolumID: mtBolumID || anyMatch?.nUrtMTBolumID || anyMatch?.NUrtMTBolumID || 0, nUrtMTBolumID: mtBolumID || anyMatch?.nUrtMTBolumID || anyMatch?.NUrtMTBolumID || 0,
@@ -3124,7 +3291,8 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) {
isNew: true, isNew: true,
nUrtMTBolumID: mtBolumID, nUrtMTBolumID: mtBolumID,
nOnMLNo: detailHeader.value?.nOnMLNo || onMLNo.value || '', 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 // Group header key
sAciklama3: effectiveGroupName, sAciklama3: effectiveGroupName,
// Parca adi column value (CEKET/PANTOLON/YELEK...) // 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: // CM2 special case:
// Bulk autofill is disabled for CM2 (and FABRIC) to prevent wrong price pulls, // 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. // but we still want the I.* code templates (code + description) to appear on page open.
@@ -3243,6 +3547,9 @@ function computeMissingRequiredSlots () {
const missing = [] const missing = []
if (list.length === 0) return missing if (list.length === 0) return missing
// Expand suppression keys: support both mappingMtBolumID and meta-derived mtBolumID.
const suppressed = suppressedRequiredKeys.value || {}
list.forEach(mapping => { list.forEach(mapping => {
// Required slots are defined per (ParcaBolum / MTBolumID) + (HammaddeTuruNo). // 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. // 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 => { hList.forEach(hNoRaw => {
const hNo = normalizeHammaddeNo(hNoRaw) const hNo = normalizeHammaddeNo(hNoRaw)
if (!hNo) return 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 => { const match = flatDetailRows.value.find(r => {
if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false
@@ -3294,6 +3628,16 @@ function removeDetailRowByKey (rowKey) {
detailGroups.value = nextGroups detailGroups.value = nextGroups
syncAllGroupsOpen() 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) { if (existingRow && !existingRow?.isNew) {
deletedDetailRows.value = [ deletedDetailRows.value = [
...(Array.isArray(deletedDetailRows.value) ? deletedDetailRows.value : []), ...(Array.isArray(deletedDetailRows.value) ? deletedDetailRows.value : []),
@@ -3588,17 +3932,79 @@ async function confirmRefresh () {
await fetchDetail({ clearDraft: true, hydrateDraft: false }) await fetchDetail({ clearDraft: true, hydrateDraft: false })
} }
function scrollToFirstRequiredAttentionRow () {
// Best-effort DOM scroll: q-table renders rowClass on the <tr>.
// 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 () { async function saveChanges () {
saveLoading.value = true saveLoading.value = true
try { try {
requiredAttentionRowKeys.value = {} requiredAttentionRowKeys.value = {}
if (isNoCostDetail.value) { if (isNoCostDetail.value) {
const missing = computeMissingRequiredSlots() const missing = computeMissingRequiredSlots()
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) { 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 `
<tr>
<td style="padding:4px 6px; border-bottom:1px solid #eee;">${i + 1}</td>
<td style="padding:4px 6px; border-bottom:1px solid #eee;">${String(m?.parcaAdi || '').trim()}</td>
<td style="padding:4px 6px; border-bottom:1px solid #eee;">${String(m?.nHammaddeTuruNo || '').trim()}</td>
<td style="padding:4px 6px; border-bottom:1px solid #eee; color:#b71c1c;">${code || '-'}</td>
<td style="padding:4px 6px; border-bottom:1px solid #eee; color:#b71c1c;">${desc || '-'}</td>
</tr>
`
}).join('')
const truncatedNote = missing.length > 40
? `<div style="margin-top:8px; color:#777;">Ilk 40 satir gosterildi. Toplam: ${missing.length}</div>`
: ''
const ok = await new Promise(resolve => { const ok = await new Promise(resolve => {
$q.dialog({ $q.dialog({
title: 'Eksik Maliyet Parcalari', title: 'Eksik Maliyet Parcalari',
message: `Eslestirilen parcalarda fiyat girilmemis satirlar var. Devam etmek istiyor musunuz? (Eksik: ${missing.length})`, html: true,
message: `
<div>
Eslestirilen parcalarda fiyat girilmemis satirlar var. Devam etmek istiyor musunuz?
<div style="margin-top:6px; font-weight:600;">Eksik: ${missing.length}</div>
<div style="margin-top:8px; max-height:280px; overflow:auto; border:1px solid #eee;">
<table style="width:100%; border-collapse:collapse; font-size:12px;">
<thead>
<tr style="text-align:left; background:#fafafa;">
<th style="padding:4px 6px; border-bottom:1px solid #eee;">No</th>
<th style="padding:4px 6px; border-bottom:1px solid #eee;">Parca</th>
<th style="padding:4px 6px; border-bottom:1px solid #eee;">Hammadde</th>
<th style="padding:4px 6px; border-bottom:1px solid #eee;">Kod</th>
<th style="padding:4px 6px; border-bottom:1px solid #eee;">Aciklama</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
</div>
${truncatedNote}
</div>
`,
cancel: true, cancel: true,
persistent: true persistent: true
}).onOk(() => resolve(true)).onCancel(() => resolve(false)) }).onOk(() => resolve(true)).onCancel(() => resolve(false))
@@ -3614,6 +4020,8 @@ async function saveChanges () {
message: 'Eksik parcalar isaretlendi. Fiyat girip tekrar deneyin.', message: 'Eksik parcalar isaretlendi. Fiyat girip tekrar deneyin.',
position: 'top-right' position: 'top-right'
}) })
// Scroll to first highlighted row.
nextTick(() => scrollToFirstRequiredAttentionRow())
return return
} }
} }
@@ -3627,6 +4035,17 @@ async function saveChanges () {
return 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. // 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. // 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 : []) const blankCodeRows = (Array.isArray(flatDetailRows.value) ? flatDetailRows.value : [])
@@ -3651,6 +4070,24 @@ async function saveChanges () {
} }
const header = detailHeader.value 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 => ({ const upserts = flatDetailRows.value.map(r => ({
n_onml_det_no: parseInt(String(r?.nOnMLDetNo || '').trim() || '0', 10) || 0, n_onml_det_no: parseInt(String(r?.nOnMLDetNo || '').trim() || '0', 10) || 0,
n_hammadde_turu_no: parseInt(String(r?.nHammaddeTuruNo || '').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( watch(
() => [onMLNo.value, productCode.value, recipeCode.value, detailSource.value], () => [onMLNo.value, productCode.value, recipeCode.value, detailSource.value],
() => { () => {
@@ -4062,6 +4520,22 @@ watch(
overflow: visible !important; 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) { .pcd-detail-table :deep(.q-table thead) {
display: table-header-group !important; display: table-header-group !important;
} }