Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -912,19 +912,22 @@ func GetProductionHasCostDetailRowsByOnMLNo(
|
||||
nOnMLNo int,
|
||||
) (*sql.Rows, error) {
|
||||
sqlText := `
|
||||
SELECT
|
||||
ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ') AS sAciklama3,
|
||||
SELECT
|
||||
-- Prefer the group label stored on the OnML detail row (D.sAciklama3),
|
||||
-- because some hammadde type master rows may have empty/legacy group labels.
|
||||
ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ')) AS sAciklama3,
|
||||
SUM(ISNULL(D.lTutar, 0)) OVER (
|
||||
PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(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,
|
||||
@@ -945,13 +948,20 @@ SELECT
|
||||
ISNULL(D.sBirim, '') AS sBirim,
|
||||
ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi,
|
||||
ISNULL(B.sAdi, '') AS sParcaAdi
|
||||
FROM dbo.spUrtOnMLMasDet D
|
||||
LEFT JOIN dbo.spUrtOnMLHammaddeTuru T
|
||||
FROM dbo.spUrtOnMLMasDet D
|
||||
LEFT JOIN dbo.spUrtOnMLHammaddeTuru T
|
||||
ON T.nHammaddeTuruNo = D.nHammaddeTuruNo
|
||||
LEFT JOIN dbo.spUrtMTBolum B
|
||||
LEFT JOIN dbo.spUrtMTBolum B
|
||||
ON B.nUrtMTBolumID = D.nUrtMTBolumID
|
||||
WHERE D.nOnMLNo = @p1
|
||||
ORDER BY
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1
|
||||
LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) AS sModel,
|
||||
LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sAciklama, '')))) AS sAciklama
|
||||
FROM dbo.tbStok S WITH (NOLOCK)
|
||||
WHERE LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) = LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(D.sKodu, ''))))
|
||||
) SX
|
||||
WHERE D.nOnMLNo = @p1
|
||||
ORDER BY
|
||||
GroupTotalTutar DESC,
|
||||
sAciklama3 ASC,
|
||||
ISNULL(D.lTutar, 0) DESC,
|
||||
@@ -1219,26 +1229,40 @@ func GetProductionHasCostDetailHammaddeTypeOptions(
|
||||
uretimDB *sql.DB,
|
||||
search string,
|
||||
limit int,
|
||||
group string,
|
||||
onlyActive *bool,
|
||||
) (*sql.Rows, error) {
|
||||
search = strings.TrimSpace(search)
|
||||
group = strings.TrimSpace(group)
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
searchLike := "%" + search + "%"
|
||||
|
||||
sqlText := `
|
||||
SELECT TOP (@p2)
|
||||
SELECT TOP (@p2)
|
||||
RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo,
|
||||
ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi,
|
||||
COALESCE(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(T.sAciklama2)), ''), N'TANIMSIZ') AS sAciklama3,
|
||||
ISNULL(T.MTnUrtMTBolumID, 0) AS mtUrtMTBolumID,
|
||||
ISNULL(B.sAdi, '') AS sParcaAdi
|
||||
FROM dbo.spUrtOnMLHammaddeTuru T WITH (NOLOCK)
|
||||
LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK)
|
||||
FROM dbo.spUrtOnMLHammaddeTuru T WITH (NOLOCK)
|
||||
LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK)
|
||||
ON B.nUrtMTBolumID = T.MTnUrtMTBolumID
|
||||
AND ISNULL(B.nUrtTipiID, 0) = 1
|
||||
WHERE
|
||||
ISNULL(T.bAktif, 0) = 1
|
||||
WHERE
|
||||
-- Active filtering:
|
||||
-- - By default, list active rows.
|
||||
-- - If searching by exact number, allow inactive too (legacy CM2 etc.).
|
||||
-- - If onlyActive flag is provided, honor it explicitly.
|
||||
(
|
||||
(@p5 IS NULL AND (ISNULL(T.bAktif, 0) = 1 OR (@p1 <> '' AND RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) = @p1)))
|
||||
OR (@p5 IS NOT NULL AND ISNULL(T.bAktif, 0) = @p5)
|
||||
)
|
||||
AND (
|
||||
@p4 = ''
|
||||
OR COALESCE(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(T.sAciklama2)), ''), N'TANIMSIZ') = @p4
|
||||
)
|
||||
AND (
|
||||
@p1 = ''
|
||||
OR RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) LIKE @p3
|
||||
@@ -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
|
||||
FROM dbo.tbStok S
|
||||
WHERE
|
||||
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)
|
||||
`
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
400
svc/routes/production_product_costing_pdf.go
Normal file
400
svc/routes/production_product_costing_pdf.go
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user