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",
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",

View File

@@ -913,18 +913,21 @@ func GetProductionHasCostDetailRowsByOnMLNo(
) (*sql.Rows, error) {
sqlText := `
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 (
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,
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,
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,
-- 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,
@@ -950,6 +953,13 @@ 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,
@@ -1219,8 +1229,11 @@ 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
}
@@ -1238,7 +1251,18 @@ LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK)
ON B.nUrtMTBolumID = T.MTnUrtMTBolumID
AND ISNULL(B.nUrtTipiID, 0) = 1
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 (
@p1 = ''
OR RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) 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 := `
SELECT TOP (@p2)
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
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
)
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
)
SELECT TOP (@p2)
nStokID,
sKodu,
sAciklama,
sModel,
sBirim
FROM c
WHERE rn = 1
ORDER BY match_rank, sModel, sKodu
OPTION (RECOMPILE)
`

View File

@@ -767,7 +767,15 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht
return
}
rows, err := queries.GetProductionHasCostDetailHammaddeTypeOptions(ctx, uretimDB, search, limit)
group := strings.TrimSpace(r.URL.Query().Get("group"))
rawOnlyActive := strings.TrimSpace(r.URL.Query().Get("only_active"))
var onlyActive *bool = nil
if rawOnlyActive != "" {
v := rawOnlyActive == "1" || strings.EqualFold(rawOnlyActive, "true")
onlyActive = &v
}
rows, err := queries.GetProductionHasCostDetailHammaddeTypeOptions(ctx, uretimDB, search, limit, group, onlyActive)
if err != nil {
logger.Error("hammadde query error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] hammadde query error: %v", err)
@@ -839,8 +847,14 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht
continue
}
item.Kind = "item"
item.Value = item.SKodu
item.Label = strings.TrimSpace(item.SKodu + " - " + item.SAciklama)
// Use variantless code (tbStok.sModel) as the value for costing/recipe.
// Variant-coded tbStok.sKodu can include color/variant suffixes; costing uses base model codes.
code := strings.TrimSpace(item.SModel)
if code == "" {
code = strings.TrimSpace(item.SKodu)
}
item.Value = code
item.Label = strings.TrimSpace(code + " - " + item.SAciklama)
list = append(list, item)
}
if err := rows.Err(); err != nil {
@@ -1237,6 +1251,12 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
return
}
}
// Header validation: uretim sekli must be selected.
if req.Header.UretimSekliID <= 0 {
logger.Warn("validation failed: uretim_sekli_id <= 0", "uretim_sekli_id", req.Header.UretimSekliID)
http.Error(w, "Uretim sekli secilmeden kayit yapilamaz", http.StatusBadRequest)
return
}
req.DetailSource = strings.ToLower(strings.TrimSpace(req.DetailSource))
req.Header.UrunKodu = strings.TrimSpace(req.Header.UrunKodu)
@@ -1457,8 +1477,10 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
// Deletes
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "detail_deletes", "count", len(req.Detail.Deletes))
skippedDeletes := 0
for _, d := range req.Detail.Deletes {
if d.NOnMLDetNo <= 0 {
skippedDeletes += 1
continue
}
if _, err := tx.ExecContext(ctx, `DELETE FROM dbo.spUrtOnMLMasDet WHERE nOnMLNo=@p1 AND nOnMLDetNo=@p2`, nOnMLNo, d.NOnMLDetNo); err != nil {
@@ -1467,13 +1489,31 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
return
}
}
if skippedDeletes > 0 {
logger.Warn("detail deletes skipped (det_no<=0)", "trace_id", traceID, "n_onml_no", nOnMLNo, "skipped", skippedDeletes)
}
// Upserts
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "detail_upserts", "count", len(req.Detail.Upserts))
skippedUpserts := 0
skippedUpsertsSample := 0
for _, row := range req.Detail.Upserts {
if row.NOnMLDetNo <= 0 {
skippedUpserts += 1
if skippedUpsertsSample < 5 {
skippedUpsertsSample += 1
logger.Warn("detail upsert skipped (det_no<=0)",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"n_hammadde_turu_no", row.NHammaddeTuruNo,
"s_kodu", strings.TrimSpace(row.SKodu),
"s_aciklama3", strings.TrimSpace(row.SAciklama3),
)
}
continue
}
if row.NHammaddeTuruNo <= 0 || strings.TrimSpace(row.SKodu) == "" {
// FALLBACK: If nHammaddeTuruNo is missing but sKodu is present, default to 1 (General/Labor)
// to avoid blocking the user, especially for labor items.
@@ -1701,140 +1741,12 @@ 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
if skippedUpserts > 0 {
logger.Warn("detail upserts skipped summary (det_no<=0)", "trace_id", traceID, "n_onml_no", nOnMLNo, "skipped", skippedUpserts)
}
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)
}
}
}
// 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 {
@@ -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 {
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,
"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 != "" {
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))
}
}
}

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"
@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
label="Kaydi Sil"
icon="delete"
@@ -244,6 +254,9 @@
{{ grp.sAciklama3 || 'TANIMSIZ' }}
</div>
<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)) }}
<q-icon
:name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'"
@@ -529,6 +542,16 @@
@filter="filterRowEditorItemOptions"
@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 class="col-12 col-md-6">
<q-select
@@ -599,6 +622,41 @@
</q-card>
</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-card class="pcd-history-dialog">
<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 { 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,7 +2379,11 @@ async function fetchDetail (options = {}) {
if (hydrateDraft) {
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 {
const mappings = await fetchRequiredParcaMappings()
await ensureNoCostRequiredRowsFromMappings(mappings)
@@ -2274,6 +2393,7 @@ async function fetchDetail (options = {}) {
detail: await extractApiErrorDetail(err)
})
}
}
const initialOpen = {}
detailGroups.value.forEach((grp, gi) => {
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) {
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 <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 () {
saveLoading.value = true
try {
requiredAttentionRowKeys.value = {}
if (isNoCostDetail.value) {
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) {
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 => {
$q.dialog({
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,
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;
}