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

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

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;
}